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()?.onComplete() + } + + manager.loadRemoteConfigList(callback) + shadowOf(Looper.getMainLooper()).idle() + serviceCallback.captured.onSuccess(QRemoteConfigList(listOf(current))) + + assertEquals(listOf(current), persistentCache.getAll().remoteConfigs) + verify(exactly = 1) { callback.onSuccess(match { it.remoteConfigs == listOf(current) }) } + } + + @Test + fun `user switch mid-flight reissues list and never delivers prior identity config`() { + userStateProvider.stable = true + val priorIdentityConfig = remoteConfigFor("ctx") + val currentIdentityConfig = remoteConfigFor("ctx") + val callback = mockk(relaxed = true) + val serviceCallbacks = mutableListOf() + every { + mockRemoteConfigService.loadRemoteConfigs(listOf("ctx"), false, capture(serviceCallbacks)) + } just runs + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfigList(listOf("ctx"), false, callback) + shadowOf(Looper.getMainLooper()).idle() + manager.onUserUpdate() + shadowOf(Looper.getMainLooper()).idle() + serviceCallbacks.first().onSuccess(QRemoteConfigList(listOf(priorIdentityConfig))) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(2, serviceCallbacks.size) + verify(exactly = 0) { callback.onSuccess(match { priorIdentityConfig in it.remoteConfigs }) } + assertTrue(persistentCache.savedConfigs.isEmpty()) + + serviceCallbacks.last().onSuccess(QRemoteConfigList(listOf(currentIdentityConfig))) + + verify(exactly = 1) { callback.onSuccess(match { it.remoteConfigs == listOf(currentIdentityConfig) }) } + assertEquals(listOf(currentIdentityConfig), persistentCache.savedConfigs) + } + + @Test + fun `user switch mid-flight reissues a failed list before consulting persistent fallback`() { + userStateProvider.stable = true + val priorIdentityConfig = remoteConfigFor("ctx") + val currentIdentityConfig = remoteConfigFor("ctx") + persistentCache.save(priorIdentityConfig) + val callback = mockk(relaxed = true) + val serviceCallbacks = mutableListOf() + every { + mockRemoteConfigService.loadRemoteConfigs(listOf("ctx"), false, capture(serviceCallbacks)) + } just runs + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfigList(listOf("ctx"), false, callback) + shadowOf(Looper.getMainLooper()).idle() + manager.onUserUpdate() + shadowOf(Looper.getMainLooper()).idle() + persistentCache.savedConfigs.clear() + persistentCache.save(currentIdentityConfig) + serviceCallbacks.first().onError(QonversionError(QonversionErrorCode.NetworkConnectionFailed)) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(2, serviceCallbacks.size) + verify { callback wasNot Called } + + serviceCallbacks.last().onError(QonversionError(QonversionErrorCode.NetworkConnectionFailed)) + + verify(exactly = 1) { callback.onSuccess(match { it.remoteConfigs == listOf(currentIdentityConfig) }) } + verify(exactly = 0) { callback.onSuccess(match { priorIdentityConfig in it.remoteConfigs }) } + verify(exactly = 0) { callback.onError(any()) } + } + + @Test + fun `non-recoverable error from a reissued list is not masked by persistent fallback`() { + userStateProvider.stable = true + val stale = remoteConfigFor("ctx") + persistentCache.save(stale) + val callback = mockk(relaxed = true) + val serviceCallbacks = mutableListOf() + every { + mockRemoteConfigService.loadRemoteConfigs(listOf("ctx"), false, capture(serviceCallbacks)) + } just runs + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfigList(listOf("ctx"), false, callback) + shadowOf(Looper.getMainLooper()).idle() + manager.onUserUpdate() + shadowOf(Looper.getMainLooper()).idle() + serviceCallbacks.first().onError(QonversionError(QonversionErrorCode.NetworkConnectionFailed)) + shadowOf(Looper.getMainLooper()).idle() + val authError = QonversionError(QonversionErrorCode.InvalidCredentials, httpCode = 401) + serviceCallbacks.last().onError(authError) + + verify(exactly = 1) { callback.onError(authError) } + verify(exactly = 0) { callback.onSuccess(match { stale in it.remoteConfigs }) } + } + + @Test + fun `offline requested list fills cache misses from bundle but persistent values win`() { + userStateProvider.stable = true + val lastKnownGood = remoteConfigFor("first") + val bundledForSameKey = remoteConfigFor("first") + val bundledForMissingKey = remoteConfigFor("second") + persistentCache.save(lastKnownGood) + every { mockFallbacksService.obtainFallbackData() } returns QFallbackObject( + offerings = null, + productPermissions = null, + remoteConfigList = QRemoteConfigList(listOf(bundledForSameKey, bundledForMissingKey)), + ) + 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.onError(QonversionError(QonversionErrorCode.NetworkConnectionFailed)) + + verify(exactly = 1) { + callback.onSuccess(match { it.remoteConfigs == listOf(lastKnownGood, bundledForMissingKey) }) + } + verify(exactly = 0) { callback.onError(any()) } + assertEquals(QRemoteConfigDeliveryOrigin.PersistentLastKnownGood, manager.lastDeliveryOrigin("first")) + assertEquals(QRemoteConfigDeliveryOrigin.BundledFallback, manager.lastDeliveryOrigin("second")) + } + + @Test + fun `offline all-context list serves persistent values before bundled list`() { + userStateProvider.stable = true + val first = remoteConfigFor("first") + val second = remoteConfigFor("second") + persistentCache.save(first) + persistentCache.save(second) + val bundled = remoteConfigFor("bundled") + every { mockFallbacksService.obtainFallbackData() } returns QFallbackObject( + offerings = null, + productPermissions = null, + remoteConfigList = QRemoteConfigList(listOf(bundled)), + ) + 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.onError(QonversionError(QonversionErrorCode.NetworkConnectionFailed)) + + verify(exactly = 1) { callback.onSuccess(match { it.remoteConfigs == listOf(first, second, bundled) }) } + verify(exactly = 0) { callback.onError(any()) } + } + @Test fun `loadRemoteConfigList from a background thread defers the listRequests mutation to the main thread`() { // given - the user is not stable, so loadRemoteConfigList enqueues the request @@ -289,10 +1029,11 @@ internal class QRemoteConfigManagerTest { verify(exactly = 1) { callback.onSuccess(any()) } verify { mockRemoteConfigService wasNot Called } verify(exactly = 1) { mockUserPropertiesManager.forceSendProperties(any()) } + assertEquals(QRemoteConfigDeliveryOrigin.MemoryCache, manager.lastDeliveryOrigin("ctx")) } @Test - fun `loadRemoteConfigList cache hit does not flush properties while the user is unstable`() { + fun `loadRemoteConfigList cache hit waits for stable identity before serving or flushing`() { // given - cached configs, but the user is mid-identify. The stability // gate exists so the flush cannot POST to a switching uid. userStateProvider.stable = false @@ -303,9 +1044,147 @@ internal class QRemoteConfigManagerTest { // when manager.loadRemoteConfigList(listOf("ctx"), false, callback) - // then - the cached list is still served, but nothing is flushed - verify(exactly = 1) { callback.onSuccess(any()) } + // then - neither stale memory nor a properties request can cross the + // identity boundary. The request is retained for replay after identify. + verify(exactly = 0) { callback.onSuccess(any()) } verify(exactly = 0) { mockUserPropertiesManager.forceSendProperties(any()) } + assertEquals(1, listRequests().size) + } + + @Test + fun `queued single waiter survives identity state reset and replays exactly once`() { + userStateProvider.stable = false + 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) + manager.onUserUpdate { + persistentCache.scope = persistentCache.scope.copy(userId = "user-b") + } + shadowOf(Looper.getMainLooper()).idle() + + userStateProvider.stable = true + manager.handlePendingRequests() + shadowOf(Looper.getMainLooper()).idle() + assertEquals(1, serviceCallbacks.size) + + val currentConfig = remoteConfigFor("ctx") + serviceCallbacks.single().onSuccess(currentConfig) + shadowOf(Looper.getMainLooper()).idle() + + verify(exactly = 1) { callback.onSuccess(currentConfig) } + verify(exactly = 0) { callback.onError(any()) } + } + + @Test + fun `single preflight completion while identity is unstable defers request and waiter`() { + userStateProvider.stable = true + val callback = mockk(relaxed = true) + val propertyCallbacks = mutableListOf() + val serviceCallbacks = mutableListOf() + every { mockUserPropertiesManager.forceSendProperties(capture(propertyCallbacks)) } just runs + every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallbacks)) } just runs + + manager.loadRemoteConfig("ctx", callback) + userStateProvider.stable = false + propertyCallbacks.single().onComplete() + shadowOf(Looper.getMainLooper()).idle() + + assertTrue(serviceCallbacks.isEmpty()) + verify { callback wasNot Called } + + userStateProvider.stable = true + manager.handlePendingRequests() + propertyCallbacks.last().onComplete() + shadowOf(Looper.getMainLooper()).idle() + val currentConfig = remoteConfigFor("ctx") + serviceCallbacks.single().onSuccess(currentConfig) + + verify(exactly = 1) { callback.onSuccess(currentConfig) } + verify(exactly = 0) { callback.onError(any()) } + } + + @Test + fun `single response while identity is unstable is reissued and delivered exactly once`() { + userStateProvider.stable = true + 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() + userStateProvider.stable = false + val unstableConfig = remoteConfigFor("ctx") + serviceCallbacks.first().onSuccess(unstableConfig) + shadowOf(Looper.getMainLooper()).idle() + + verify { callback wasNot Called } + assertTrue(persistentCache.savedConfigs.isEmpty()) + + userStateProvider.stable = true + manager.handlePendingRequests() + shadowOf(Looper.getMainLooper()).idle() + val currentConfig = remoteConfigFor("ctx") + serviceCallbacks.last().onSuccess(currentConfig) + + verify(exactly = 0) { callback.onSuccess(unstableConfig) } + verify(exactly = 1) { callback.onSuccess(currentConfig) } + verify(exactly = 0) { callback.onError(any()) } + assertEquals(listOf(currentConfig), persistentCache.savedConfigs) + } + + @Test + fun `list preflight and response both wait for stable identity`() { + userStateProvider.stable = true + val preflightCallback = mockk(relaxed = true) + val preflightPropertyCallbacks = mutableListOf() + val serviceCallbacks = mutableListOf() + every { mockUserPropertiesManager.forceSendProperties(capture(preflightPropertyCallbacks)) } just runs + every { + mockRemoteConfigService.loadRemoteConfigs(listOf("ctx"), false, capture(serviceCallbacks)) + } just runs + + manager.loadRemoteConfigList(listOf("ctx"), false, preflightCallback) + userStateProvider.stable = false + preflightPropertyCallbacks.single().onComplete() + shadowOf(Looper.getMainLooper()).idle() + + assertTrue(serviceCallbacks.isEmpty()) + verify { preflightCallback wasNot Called } + + userStateProvider.stable = true + manager.handlePendingRequests() + preflightPropertyCallbacks.last().onComplete() + shadowOf(Looper.getMainLooper()).idle() + assertEquals(1, serviceCallbacks.size) + + userStateProvider.stable = false + val unstableConfig = remoteConfigFor("ctx") + serviceCallbacks.single().onSuccess(QRemoteConfigList(listOf(unstableConfig))) + shadowOf(Looper.getMainLooper()).idle() + + verify { preflightCallback wasNot Called } + assertTrue(persistentCache.savedConfigs.isEmpty()) + + userStateProvider.stable = true + manager.handlePendingRequests() + preflightPropertyCallbacks.last().onComplete() + shadowOf(Looper.getMainLooper()).idle() + val currentConfig = remoteConfigFor("ctx") + serviceCallbacks.last().onSuccess(QRemoteConfigList(listOf(currentConfig))) + + verify(exactly = 1) { + preflightCallback.onSuccess(match { it.remoteConfigs == listOf(currentConfig) }) + } + verify(exactly = 0) { preflightCallback.onError(any()) } + assertEquals(listOf(currentConfig), persistentCache.savedConfigs) } @Test @@ -369,7 +1248,7 @@ internal class QRemoteConfigManagerTest { // response lands manager.invalidateRemoteConfigsCache() shadowOf(Looper.getMainLooper()).idle() - val staleConfig = mockk(relaxed = true) + val staleConfig = remoteConfigFor("ctx") serviceCallbacks.first().onSuccess(staleConfig) // then - the stale evaluation is neither cached nor delivered; the @@ -378,7 +1257,7 @@ internal class QRemoteConfigManagerTest { verify(exactly = 2) { mockRemoteConfigService.loadRemoteConfig("ctx", any()) } // and the fresh response is delivered, cached, and the state settled - val freshConfig = mockk(relaxed = true) + val freshConfig = remoteConfigFor("ctx") serviceCallbacks.last().onSuccess(freshConfig) verify(exactly = 1) { loadCallback.onSuccess(freshConfig) } verify(exactly = 0) { loadCallback.onSuccess(staleConfig) } @@ -405,7 +1284,7 @@ internal class QRemoteConfigManagerTest { manager.attachUserToRemoteConfiguration("config_id", mockk(relaxed = true)) shadowOf(Looper.getMainLooper()).idle() - val staleConfig = mockk(relaxed = true) + val staleConfig = remoteConfigFor("ctx") serviceCallbacks.first().onSuccess(staleConfig) // then - the pre-attach evaluation is dropped and the load re-issued @@ -440,7 +1319,7 @@ internal class QRemoteConfigManagerTest { val warmConfig = mockk(relaxed = true) every { warmConfig.source.contextKey } returns "ctx" listServiceCallback.captured.onSuccess(QRemoteConfigList(listOf(warmConfig))) - serviceCallbacks.first().onSuccess(mockk(relaxed = true)) + serviceCallbacks.first().onSuccess(remoteConfigFor("ctx")) // then - the waiter is served exactly once with the warm (current // generation) config instead of hanging forever, and no second @@ -455,29 +1334,79 @@ internal class QRemoteConfigManagerTest { fun `a failed re-issue delivers the superseded evaluation instead of an error`() { // given - a load with a waiter is in flight userStateProvider.stable = true - val loadCallback = mockk(relaxed = true) + val loadCallback = mockk(relaxed = true) + val serviceCallbacks = mutableListOf() + every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallbacks)) } just runs + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + manager.loadRemoteConfig("ctx", loadCallback) + shadowOf(Looper.getMainLooper()).idle() + + // when - invalidation mid-flight, the superseded (valid) response + // triggers a re-issue, and the retry fails transiently + manager.invalidateRemoteConfigsCache() + shadowOf(Looper.getMainLooper()).idle() + val supersededConfig = remoteConfigFor("ctx") + serviceCallbacks.first().onSuccess(supersededConfig) + serviceCallbacks.last().onError(QonversionError(QonversionErrorCode.NetworkConnectionFailed)) + + // then - never worse than before: the superseded evaluation is + // delivered as a success instead of surfacing the retry error, and + // nothing is cached + verify(exactly = 1) { loadCallback.onSuccess(supersededConfig) } + verify(exactly = 0) { loadCallback.onError(any()) } + assertEquals(null, loadingStates()["ctx"]?.loadedConfig) + } + + @Test + fun `a non-recoverable re-issue error is not masked by the superseded evaluation`() { + userStateProvider.stable = true + 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() + shadowOf(Looper.getMainLooper()).idle() + val supersededConfig = remoteConfigFor("ctx") + serviceCallbacks.first().onSuccess(supersededConfig) + val authError = QonversionError(QonversionErrorCode.InvalidCredentials, httpCode = 401) + serviceCallbacks.last().onError(authError) + + verify(exactly = 1) { callback.onError(authError) } + verify(exactly = 0) { callback.onSuccess(supersededConfig) } + assertEquals(null, manager.lastDeliveryOrigin("ctx")) + } + + @Test + fun `authoritative no-config during re-issue evicts disk and is not masked by baseline`() { + userStateProvider.stable = true + val stale = remoteConfigFor("ctx") + persistentCache.save(stale) + 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", loadCallback) + manager.loadRemoteConfig("ctx", callback) shadowOf(Looper.getMainLooper()).idle() - // when - invalidation mid-flight, the superseded (valid) response - // triggers a re-issue, and the retry fails without a fallback manager.invalidateRemoteConfigsCache() shadowOf(Looper.getMainLooper()).idle() - val supersededConfig = mockk(relaxed = true) + val supersededConfig = remoteConfigFor("ctx") serviceCallbacks.first().onSuccess(supersededConfig) - serviceCallbacks.last().onError(QonversionError(QonversionErrorCode.BackendError)) + val noConfig = QonversionError(QonversionErrorCode.RemoteConfigurationNotAvailable) + serviceCallbacks.last().onError(noConfig) - // then - never worse than before: the superseded evaluation is - // delivered as a success instead of surfacing the retry error, and - // nothing is cached - verify(exactly = 1) { loadCallback.onSuccess(supersededConfig) } - verify(exactly = 0) { loadCallback.onError(any()) } - assertEquals(null, loadingStates()["ctx"]?.loadedConfig) + verify(exactly = 1) { callback.onError(noConfig) } + verify(exactly = 0) { callback.onSuccess(any()) } + assertEquals(null, persistentCache.get("ctx")) } @Test @@ -505,7 +1434,7 @@ internal class QRemoteConfigManagerTest { // triggers a re-issue, and the retry fails in a FALLBACK-ELIGIBLE way manager.invalidateRemoteConfigsCache() shadowOf(Looper.getMainLooper()).idle() - val supersededConfig = mockk(relaxed = true) + val supersededConfig = remoteConfigFor("ctx") serviceCallbacks.first().onSuccess(supersededConfig) serviceCallbacks.last().onError(QonversionError(QonversionErrorCode.NetworkConnectionFailed)) @@ -531,14 +1460,14 @@ internal class QRemoteConfigManagerTest { // when - invalidation mid-flight, the superseded response triggers a // re-issue, a SECOND caller joins while the retry is flying, and the - // retry fails without a fallback + // retry fails transiently manager.invalidateRemoteConfigsCache() shadowOf(Looper.getMainLooper()).idle() - val supersededConfig = mockk(relaxed = true) + val supersededConfig = remoteConfigFor("ctx") serviceCallbacks.first().onSuccess(supersededConfig) val callbackB = mockk(relaxed = true) manager.loadRemoteConfig("ctx", callbackB) - serviceCallbacks.last().onError(QonversionError(QonversionErrorCode.BackendError)) + serviceCallbacks.last().onError(QonversionError(QonversionErrorCode.NetworkConnectionFailed)) // then - the never-worse guarantee is uniform: the late joiner gets // the baseline too, not the retry error @@ -562,8 +1491,8 @@ internal class QRemoteConfigManagerTest { shadowOf(Looper.getMainLooper()).idle() manager.invalidateRemoteConfigsCache() shadowOf(Looper.getMainLooper()).idle() - serviceCallbacks.first().onSuccess(mockk(relaxed = true)) - serviceCallbacks.last().onSuccess(mockk(relaxed = true)) + serviceCallbacks.first().onSuccess(remoteConfigFor("ctx")) + serviceCallbacks.last().onSuccess(remoteConfigFor("ctx")) // when - a later, unrelated load for the same key fails manager.invalidateRemoteConfigsCache() @@ -603,7 +1532,7 @@ internal class QRemoteConfigManagerTest { val warmConfig = mockk(relaxed = true) every { warmConfig.source.contextKey } returns "ctx" listServiceCallback.captured.onSuccess(QRemoteConfigList(listOf(warmConfig))) - serviceCallbacks.first().onSuccess(mockk(relaxed = true)) + serviceCallbacks.first().onSuccess(remoteConfigFor("ctx")) verify(exactly = 1) { loadCallback.onSuccess(warmConfig) } // when - a later, unrelated load for the same key fails @@ -636,7 +1565,7 @@ internal class QRemoteConfigManagerTest { manager.invalidateRemoteConfigsCache() shadowOf(Looper.getMainLooper()).idle() userStateProvider.stable = false - serviceCallbacks.first().onSuccess(mockk(relaxed = true)) + serviceCallbacks.first().onSuccess(remoteConfigFor("ctx")) manager.userChangingRequestFailedWithError(QonversionError(QonversionErrorCode.BackendError)) shadowOf(Looper.getMainLooper()).idle() @@ -672,7 +1601,7 @@ internal class QRemoteConfigManagerTest { } @Test - fun `a user switch mid-flight does not trigger an unrequested re-issue`() { + fun `a user switch mid-flight reissues an awaited single load`() { // given - a load with a waiter is in flight userStateProvider.stable = true val loadCallback = mockk(relaxed = true) @@ -688,10 +1617,255 @@ internal class QRemoteConfigManagerTest { // the now-orphaned state manager.onUserUpdate() shadowOf(Looper.getMainLooper()).idle() - serviceCallbacks.first().onSuccess(mockk(relaxed = true)) + val priorIdentityConfig = remoteConfigFor("ctx") + serviceCallbacks.first().onSuccess(priorIdentityConfig) + shadowOf(Looper.getMainLooper()).idle() - // then - the orphaned state must not fire a request nobody awaits - verify(exactly = 1) { mockRemoteConfigService.loadRemoteConfig("ctx", any()) } + // then - the old identity result is dropped and the original waiter is + // carried into a request evaluated for the current identity. + verify(exactly = 2) { mockRemoteConfigService.loadRemoteConfig("ctx", any()) } + verify(exactly = 0) { loadCallback.onSuccess(priorIdentityConfig) } + + val currentIdentityConfig = remoteConfigFor("ctx") + serviceCallbacks.last().onSuccess(currentIdentityConfig) + + verify(exactly = 1) { loadCallback.onSuccess(currentIdentityConfig) } + verify(exactly = 0) { loadCallback.onError(any()) } + } + + @Test + fun `old identity single success never resolves a new identity request`() { + userStateProvider.stable = true + val oldIdentityConfig = remoteConfigFor("ctx") + val currentIdentityConfig = remoteConfigFor("ctx") + val oldCallback = mockk(relaxed = true) + val currentCallback = mockk(relaxed = true) + val serviceCallbacks = mutableListOf() + every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallbacks)) } just runs + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfig("ctx", oldCallback) + shadowOf(Looper.getMainLooper()).idle() + manager.onUserUpdate() + shadowOf(Looper.getMainLooper()).idle() + manager.loadRemoteConfig("ctx", currentCallback) + shadowOf(Looper.getMainLooper()).idle() + + serviceCallbacks.first().onSuccess(oldIdentityConfig) + shadowOf(Looper.getMainLooper()).idle() + + verify { currentCallback wasNot Called } + assertTrue(persistentCache.savedConfigs.isEmpty()) + + serviceCallbacks.last().onSuccess(currentIdentityConfig) + + verify(exactly = 1) { oldCallback.onSuccess(currentIdentityConfig) } + verify(exactly = 0) { oldCallback.onSuccess(oldIdentityConfig) } + verify(exactly = 0) { oldCallback.onError(any()) } + verify(exactly = 1) { currentCallback.onSuccess(currentIdentityConfig) } + verify(exactly = 0) { currentCallback.onSuccess(oldIdentityConfig) } + assertEquals(listOf(currentIdentityConfig), persistentCache.savedConfigs) + } + + @Test + fun `old identity single error never resolves a new identity request`() { + userStateProvider.stable = true + val currentIdentityLkg = remoteConfigFor("ctx") + val currentIdentityServerConfig = remoteConfigFor("ctx") + val oldCallback = mockk(relaxed = true) + val currentCallback = mockk(relaxed = true) + val serviceCallbacks = mutableListOf() + every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallbacks)) } just runs + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfig("ctx", oldCallback) + shadowOf(Looper.getMainLooper()).idle() + manager.onUserUpdate() + shadowOf(Looper.getMainLooper()).idle() + persistentCache.save(currentIdentityLkg) + manager.loadRemoteConfig("ctx", currentCallback) + shadowOf(Looper.getMainLooper()).idle() + + serviceCallbacks.first().onError(QonversionError(QonversionErrorCode.NetworkConnectionFailed)) + shadowOf(Looper.getMainLooper()).idle() + + verify { currentCallback wasNot Called } + + serviceCallbacks.last().onSuccess(currentIdentityServerConfig) + + verify(exactly = 1) { oldCallback.onSuccess(currentIdentityServerConfig) } + verify(exactly = 0) { oldCallback.onSuccess(currentIdentityLkg) } + verify(exactly = 0) { oldCallback.onError(any()) } + verify(exactly = 1) { currentCallback.onSuccess(currentIdentityServerConfig) } + verify(exactly = 0) { currentCallback.onSuccess(currentIdentityLkg) } + } + + @Test + fun `background identity transition reissues with current scope and never saves or delivers old identity`() { + userStateProvider.stable = true + val oldConfig = remoteConfigFor("ctx") + val currentConfig = remoteConfigFor("ctx") + val callback = mockk(relaxed = true) + val serviceCallbacks = mutableListOf() + val requestUsers = mutableListOf() + every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallbacks)) } answers { + requestUsers += persistentCache.scope.userId + } + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfig("ctx", callback) + shadowOf(Looper.getMainLooper()).idle() + val transition = Thread { + manager.onUserUpdate { + persistentCache.scope = persistentCache.scope.copy(userId = "user-b") + } + } + transition.start() + transition.join() + + serviceCallbacks.first().onSuccess(oldConfig) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(listOf("user-a", "user-b"), requestUsers) + verify { callback wasNot Called } + assertTrue(persistentCache.savedConfigs.isEmpty()) + + serviceCallbacks.last().onSuccess(currentConfig) + shadowOf(Looper.getMainLooper()).idle() + + verify(exactly = 1) { callback.onSuccess(currentConfig) } + verify(exactly = 0) { callback.onSuccess(oldConfig) } + verify(exactly = 0) { callback.onError(any()) } + assertEquals(listOf("user-b"), persistentCache.savedScopes.map { it.userId }) + } + + @Test + fun `main load immediately after background identity transition cannot join old identity state`() { + userStateProvider.stable = true + val oldCallback = mockk(relaxed = true) + val currentCallback = mockk(relaxed = true) + val serviceCallbacks = mutableListOf() + val requestUsers = mutableListOf() + every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallbacks)) } answers { + requestUsers += persistentCache.scope.userId + } + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfig("ctx", oldCallback) + shadowOf(Looper.getMainLooper()).idle() + val transition = Thread { + manager.onUserUpdate { + persistentCache.scope = persistentCache.scope.copy(userId = "user-b") + } + } + transition.start() + transition.join() + + // The transition is already complete even though its housekeeping + // runnable has not drained. This load must create current-user state, + // while the old request's waiter is transferred into that state rather + // than stranded on the orphaned user-a state. + manager.loadRemoteConfig("ctx", currentCallback) + + assertEquals(listOf("user-a", "user-b"), requestUsers) + val currentConfig = remoteConfigFor("ctx") + serviceCallbacks.last().onSuccess(currentConfig) + verify(exactly = 1) { currentCallback.onSuccess(currentConfig) } + verify(exactly = 1) { oldCallback.onSuccess(currentConfig) } + verify(exactly = 0) { oldCallback.onError(any()) } + } + + @Test + fun `identity transition during property flush only requests saves and delivers current user`() { + userStateProvider.stable = true + val currentConfig = remoteConfigFor("ctx") + val callback = mockk(relaxed = true) + val propertyCallbacks = mutableListOf() + val serviceCallbacks = mutableListOf() + val requestUsers = mutableListOf() + every { mockUserPropertiesManager.forceSendProperties(capture(propertyCallbacks)) } just runs + every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallbacks)) } answers { + requestUsers += persistentCache.scope.userId + } + + manager.loadRemoteConfig("ctx", callback) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(1, propertyCallbacks.size) + assertTrue(serviceCallbacks.isEmpty()) + + val transition = Thread { + manager.onUserUpdate { + persistentCache.scope = persistentCache.scope.copy(userId = "user-b") + } + } + transition.start() + transition.join() + + propertyCallbacks.first().onComplete() + shadowOf(Looper.getMainLooper()).idle() + assertEquals(2, propertyCallbacks.size) + assertTrue(serviceCallbacks.isEmpty()) + + propertyCallbacks.last().onComplete() + shadowOf(Looper.getMainLooper()).idle() + assertEquals(listOf("user-b"), requestUsers) + + serviceCallbacks.single().onSuccess(currentConfig) + + verify(exactly = 1) { callback.onSuccess(currentConfig) } + verify(exactly = 0) { callback.onError(any()) } + assertEquals(listOf(currentConfig), persistentCache.savedConfigs) + assertEquals(listOf("user-b"), persistentCache.savedScopes.map { it.userId }) + } + + @Test + fun `background identity transition reissues list in current scope`() { + userStateProvider.stable = true + val oldConfig = remoteConfigFor("ctx") + val currentConfig = remoteConfigFor("ctx") + val callback = mockk(relaxed = true) + val serviceCallbacks = mutableListOf() + val requestUsers = mutableListOf() + every { + mockRemoteConfigService.loadRemoteConfigs(listOf("ctx"), false, capture(serviceCallbacks)) + } answers { + requestUsers += persistentCache.scope.userId + } + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfigList(listOf("ctx"), false, callback) + shadowOf(Looper.getMainLooper()).idle() + val transition = Thread { + manager.onUserUpdate { + persistentCache.scope = persistentCache.scope.copy(userId = "user-b") + } + } + transition.start() + transition.join() + + serviceCallbacks.first().onSuccess(QRemoteConfigList(listOf(oldConfig))) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(listOf("user-a", "user-b"), requestUsers) + verify { callback wasNot Called } + assertTrue(persistentCache.savedConfigs.isEmpty()) + + serviceCallbacks.last().onSuccess(QRemoteConfigList(listOf(currentConfig))) + + verify(exactly = 1) { callback.onSuccess(match { it.remoteConfigs == listOf(currentConfig) }) } + verify(exactly = 0) { callback.onError(any()) } + assertEquals(listOf(currentConfig), persistentCache.savedConfigs) + assertEquals(listOf("user-b"), persistentCache.savedScopes.map { it.userId }) } @Test @@ -748,6 +1922,124 @@ internal class QRemoteConfigManagerTest { assertEquals(null, loadingStates()["ctx"]?.loadedConfig) } + @Test + fun `server timeout and rate limit responses deliver single persistent last known good`() { + userStateProvider.stable = true + listOf(408, 429).forEach { statusCode -> + val contextKey = "ctx_$statusCode" + val lastKnownGood = remoteConfigFor(contextKey) + persistentCache.save(lastKnownGood) + val callback = mockk(relaxed = true) + val serviceCallback = slot() + every { mockRemoteConfigService.loadRemoteConfig(contextKey, capture(serviceCallback)) } just runs + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfig(contextKey, callback) + shadowOf(Looper.getMainLooper()).idle() + serviceCallback.captured.onError( + QonversionError(QonversionErrorCode.BackendError, httpCode = statusCode), + ) + + verify(exactly = 1) { callback.onSuccess(lastKnownGood) } + verify(exactly = 0) { callback.onError(any()) } + } + } + + @Test + fun `server timeout and rate limit responses deliver list persistent last known good`() { + userStateProvider.stable = true + listOf(408, 429).forEach { statusCode -> + val contextKey = "ctx_$statusCode" + val lastKnownGood = remoteConfigFor(contextKey) + persistentCache.save(lastKnownGood) + val callback = mockk(relaxed = true) + val serviceCallback = slot() + every { + mockRemoteConfigService.loadRemoteConfigs(listOf(contextKey), false, capture(serviceCallback)) + } just runs + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfigList(listOf(contextKey), false, callback) + shadowOf(Looper.getMainLooper()).idle() + serviceCallback.captured.onError( + QonversionError(QonversionErrorCode.BackendError, httpCode = statusCode), + ) + + verify(exactly = 1) { + callback.onSuccess(match { it.remoteConfigs == listOf(lastKnownGood) }) + } + verify(exactly = 0) { callback.onError(any()) } + } + } + + @Test + fun `response parsing failure delivers persistent fallback for single and list`() { + userStateProvider.stable = true + val singleConfig = remoteConfigFor("single") + val listConfig = remoteConfigFor("list") + persistentCache.save(singleConfig) + persistentCache.save(listConfig) + val singleCallback = mockk(relaxed = true) + val listCallback = mockk(relaxed = true) + val singleServiceCallback = slot() + val listServiceCallback = slot() + every { mockRemoteConfigService.loadRemoteConfig("single", capture(singleServiceCallback)) } just runs + every { + mockRemoteConfigService.loadRemoteConfigs(listOf("list"), false, capture(listServiceCallback)) + } just runs + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfig("single", singleCallback) + manager.loadRemoteConfigList(listOf("list"), false, listCallback) + shadowOf(Looper.getMainLooper()).idle() + val parsingError = QonversionError(QonversionErrorCode.ResponseParsingFailed) + singleServiceCallback.captured.onError(parsingError) + listServiceCallback.captured.onError(parsingError) + + verify(exactly = 1) { singleCallback.onSuccess(singleConfig) } + verify(exactly = 0) { singleCallback.onError(any()) } + verify(exactly = 1) { listCallback.onSuccess(match { it.remoteConfigs == listOf(listConfig) }) } + verify(exactly = 0) { listCallback.onError(any()) } + assertEquals(singleConfig, persistentCache.get("single")) + assertEquals(listConfig, persistentCache.get("list")) + } + + @Test + fun `unknown authentication and client errors never deliver local fallback`() { + userStateProvider.stable = true + val nonTransientErrors = listOf( + QonversionError(QonversionErrorCode.Unknown), + QonversionError(QonversionErrorCode.Unknown, httpCode = 503), + QonversionError(QonversionErrorCode.InvalidCredentials), + QonversionError(QonversionErrorCode.InvalidCredentials, httpCode = 503), + QonversionError(QonversionErrorCode.BackendError, httpCode = 400), + ) + nonTransientErrors.forEachIndexed { index, error -> + val contextKey = "non_transient_$index" + val stale = remoteConfigFor(contextKey) + persistentCache.save(stale) + val callback = mockk(relaxed = true) + val serviceCallback = slot() + every { mockRemoteConfigService.loadRemoteConfig(contextKey, capture(serviceCallback)) } just runs + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfig(contextKey, callback) + shadowOf(Looper.getMainLooper()).idle() + serviceCallback.captured.onError(error) + + verify(exactly = 1) { callback.onError(error) } + verify(exactly = 0) { callback.onSuccess(stale) } + } + } + @Test fun `invalidation mid-flight does not re-issue a load nobody awaits`() { // given - a load with NO waiting callback is in flight @@ -764,7 +2056,7 @@ internal class QRemoteConfigManagerTest { // when - the cache is invalidated mid-flight, then the response lands manager.invalidateRemoteConfigsCache() shadowOf(Looper.getMainLooper()).idle() - serviceCallbacks.first().onSuccess(mockk(relaxed = true)) + serviceCallbacks.first().onSuccess(remoteConfigFor("ctx")) // then - no waiter means no retry; the superseded response is simply // not cached and the state is left refetchable @@ -964,6 +2256,31 @@ internal class QRemoteConfigManagerTest { assertEquals(false, state?.isInProgress) } + @Test + fun `named context never receives the empty-context bundled fallback`() { + userStateProvider.stable = true + val emptyContextFallback = remoteConfigFor(null) + every { mockFallbacksService.obtainFallbackData() } returns QFallbackObject( + offerings = null, + productPermissions = null, + remoteConfigList = QRemoteConfigList(listOf(emptyContextFallback)), + ) + val loadCallback = mockk(relaxed = true) + val serviceCallback = slot() + every { mockRemoteConfigService.loadRemoteConfig("missing", capture(serviceCallback)) } just runs + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfig("missing", loadCallback) + shadowOf(Looper.getMainLooper()).idle() + val networkError = QonversionError(QonversionErrorCode.NetworkConnectionFailed) + serviceCallback.captured.onError(networkError) + + verify(exactly = 0) { loadCallback.onSuccess(any()) } + verify(exactly = 1) { loadCallback.onError(networkError) } + } + @Test fun `fallback list configs are delivered without being cached`() { // given - a bundled fallback exists and a list load is in flight @@ -995,12 +2312,211 @@ internal class QRemoteConfigManagerTest { assertEquals(false, loadingStates().containsKey("ctx")) } + // region v2 identity bridge producer side + // + // QonversionInternalRemoteConfigV2WiringTest exercises the CONSUMER side of the bridge by + // firing it manually, so these tests pin the PRODUCER side: the v1 manager entry points must + // actually emit the bridge events, or v2 scope switching on identify/logout silently dies + // while every wiring test stays green. + + @Test + fun `onUserUpdate fires identityScopeChanged on the v2 bridge exactly once`() { + var identityScopeChanges = 0 + var targetingInvalidations = 0 + var bridgedExternalUserId: String? = null + manager.identityBridge.onIdentityScopeChanged = { externalUserId -> + identityScopeChanges++ + bridgedExternalUserId = externalUserId + } + manager.identityBridge.onTargetingInvalidated = { _ -> targetingInvalidations++ } + + manager.onUserUpdate("account-1") + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(1, identityScopeChanges) + assertEquals("account-1", bridgedExternalUserId) + assertEquals(0, targetingInvalidations) + } + + @Test + fun `invalidateRemoteConfigsCache fires targetingInvalidated on the v2 bridge exactly once`() { + var identityScopeChanges = 0 + var targetingInvalidations = 0 + var bridgedExternalUserId: String? = null + manager.identityBridge.onIdentityScopeChanged = { _ -> identityScopeChanges++ } + manager.identityBridge.onTargetingInvalidated = { externalUserId -> + targetingInvalidations++ + bridgedExternalUserId = externalUserId + } + + manager.invalidateRemoteConfigsCache("account-1") + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(1, targetingInvalidations) + assertEquals("account-1", bridgedExternalUserId) + assertEquals(0, identityScopeChanges) + } + + @Test + fun `a throwing v2 bridge observer does not break the v1 identity flow`() { + manager.identityBridge.onIdentityScopeChanged = { _ -> throw RuntimeException("v2 observer boom") } + manager.identityBridge.onTargetingInvalidated = { _ -> throw RuntimeException("v2 observer boom") } + var identityUpdated = false + + // Neither call may propagate the observer's exception. + manager.onUserUpdate { identityUpdated = true } + manager.invalidateRemoteConfigsCache() + shadowOf(Looper.getMainLooper()).idle() + + // The v1 side of onUserUpdate (the identity mutation) still ran to completion. + assertTrue(identityUpdated) + } + + // endregion + private fun listRequests() = manager.getPrivateField>("listRequests") private fun loadingStates() = manager.getPrivateField>("loadingStates") + private fun remoteConfigFor(contextKey: String?): QRemoteConfig { + val config = mockk() + every { config.source.contextKey } returns contextKey + return config + } + + private class FakeRemoteConfigCache : RemoteConfigCache { + private data class PendingMutation( + val apply: () -> Unit, + val completion: (Boolean) -> Unit, + ) + + val savedConfigs = mutableListOf() + var scope = RemoteConfigCacheScope("project", "Production", "user-a") + val savedScopes = mutableListOf() + var mutationCount = 0 + var deferDurableMutations = false + private val pendingMutations = ArrayDeque() + private val scopedConfigs = linkedMapOf>() + + override fun currentScope(): RemoteConfigCacheScope = scope + + override fun save(remoteConfig: QRemoteConfig) { + save(scope, remoteConfig) + } + + override fun save(scope: RemoteConfigCacheScope, remoteConfig: QRemoteConfig) { + mutationCount += 1 + savedConfigs += remoteConfig + savedScopes += scope + scopedConfigs.getOrPut(scope, ::linkedMapOf)[remoteConfig.source.contextKey] = remoteConfig + } + + override fun save( + scope: RemoteConfigCacheScope, + remoteConfig: QRemoteConfig, + completion: (Boolean) -> Unit, + ) = enqueueDurableMutation({ save(scope, remoteConfig) }, completion) + + override fun remove(contextKey: String?) { + remove(scope, contextKey) + } + + override fun remove(scope: RemoteConfigCacheScope, contextKey: String?) { + mutationCount += 1 + scopedConfigs[scope]?.remove(contextKey) + savedConfigs.removeAll { it.source.contextKey == contextKey } + } + + override fun remove( + scope: RemoteConfigCacheScope, + contextKey: String?, + completion: (Boolean) -> Unit, + ) = enqueueDurableMutation({ remove(scope, contextKey) }, completion) + + override fun replaceAll(remoteConfigs: List) { + replaceAll(scope, remoteConfigs) + } + + override fun replaceAll(scope: RemoteConfigCacheScope, remoteConfigs: List) { + mutationCount += 1 + scopedConfigs[scope] = linkedMapOf() + savedConfigs.clear() + remoteConfigs.forEach { remoteConfig -> + savedConfigs += remoteConfig + savedScopes += scope + scopedConfigs.getValue(scope)[remoteConfig.source.contextKey] = remoteConfig + } + } + + override fun replaceAll( + scope: RemoteConfigCacheScope, + remoteConfigs: List, + completion: (Boolean) -> Unit, + ) = enqueueDurableMutation({ replaceAll(scope, remoteConfigs) }, completion) + + override fun replaceRequested( + requestedContextKeys: Set, + remoteConfigs: List, + ) { + replaceRequested(scope, requestedContextKeys, remoteConfigs) + } + + override fun replaceRequested( + scope: RemoteConfigCacheScope, + requestedContextKeys: Set, + remoteConfigs: List, + ) { + mutationCount += 1 + val scoped = scopedConfigs.getOrPut(scope, ::linkedMapOf) + requestedContextKeys.forEach { contextKey -> + scoped.remove(contextKey) + savedConfigs.removeAll { it.source.contextKey == contextKey } + } + remoteConfigs.forEach { remoteConfig -> + savedConfigs += remoteConfig + savedScopes += scope + scoped[remoteConfig.source.contextKey] = remoteConfig + } + } + + override fun replaceRequested( + scope: RemoteConfigCacheScope, + requestedContextKeys: Set, + remoteConfigs: List, + completion: (Boolean) -> Unit, + ) = enqueueDurableMutation( + { replaceRequested(scope, requestedContextKeys, remoteConfigs) }, + completion, + ) + + fun completeNextDurableMutation(success: Boolean) { + val mutation = pendingMutations.removeFirst() + if (success) mutation.apply() + mutation.completion(success) + } + + private fun enqueueDurableMutation(apply: () -> Unit, completion: (Boolean) -> Unit) { + if (deferDurableMutations) { + pendingMutations.addLast(PendingMutation(apply, completion)) + } else { + apply() + completion(true) + } + } + + override fun get(contextKey: String?): QRemoteConfig? = get(scope, contextKey) + + override fun get(scope: RemoteConfigCacheScope, contextKey: String?): QRemoteConfig? = + scopedConfigs[scope]?.get(contextKey) + + override fun getAll(): QRemoteConfigList = getAll(scope) + + override fun getAll(scope: RemoteConfigCacheScope): QRemoteConfigList = + QRemoteConfigList(scopedConfigs[scope]?.values.orEmpty().toList()) + } + // Hand-written fake instead of a mockk: isUserStable is read thousands of times inside // the concurrent stress loops, and driving a mockk proxy at that volume trips a // byte-buddy instrumentation assertion under the CI JDK. A plain object keeps the hot diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/QonversionInternalRemoteConfigV2WiringTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/QonversionInternalRemoteConfigV2WiringTest.kt new file mode 100644 index 000000000..a9fa397de --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/QonversionInternalRemoteConfigV2WiringTest.kt @@ -0,0 +1,206 @@ +@file:OptIn(ExperimentalQonversionApi::class) + +package com.qonversion.android.sdk.internal + +import com.qonversion.android.sdk.ExperimentalQonversionApi +import com.qonversion.android.sdk.dto.QEnvironment +import com.qonversion.android.sdk.dto.QLaunchMode +import com.qonversion.android.sdk.dto.entitlements.QEntitlementsCacheLifetime +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchResult +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchStatus +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigV2Config +import com.qonversion.android.sdk.internal.di.QDependencyInjector +import com.qonversion.android.sdk.internal.di.component.AppComponent +import com.qonversion.android.sdk.internal.dto.config.CacheConfig +import com.qonversion.android.sdk.internal.dto.config.PrimaryConfig +import com.qonversion.android.sdk.internal.remoteconfig.MainThreadDispatcher +import com.qonversion.android.sdk.internal.remoteconfig.QRemoteConfigSnapshotsImpl +import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigFetchForceReason +import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigIdentityBridge +import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigV2Factory +import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigV2Manager +import com.qonversion.android.sdk.internal.services.QUserInfoService +import com.qonversion.android.sdk.internal.storage.SharedPreferencesCache +import com.qonversion.android.sdk.listeners.QonversionRemoteConfigFetchCallback +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkObject +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import java.util.concurrent.atomic.AtomicReference + +private const val INITIAL_UID = "initial-uid" + +/** + * Pins the init-path wiring between [QonversionInternal] and the Remote Config v2 subsystem. + * + * [com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigTelemetryFactoryDormancyTest] + * proves the factory itself builds nothing without a [QRemoteConfigV2Config]; this test pins the + * consequence one level up: an unconfigured init leaves the v1 identity bridge unbound (so both + * identity moments are safe no-ops), while a configured init force-fetches for the boot identity + * and routes both bridge moments into the v2 manager, reading the live uid each time. + */ +@RunWith(RobolectricTestRunner::class) +internal class QonversionInternalRemoteConfigV2WiringTest { + + private val appComponent = mockk(relaxed = true) + private val remoteConfigManager = mockk(relaxed = true) + private val identityBridge = RemoteConfigIdentityBridge() + private val sharedPreferencesCache = mockk(relaxed = true) + private val userInfoService = mockk() + + @Before + fun setUp() { + mockkObject(QDependencyInjector) + every { QDependencyInjector.buildAppComponent(any(), any(), any()) } returns appComponent + every { QDependencyInjector.appComponent } returns appComponent + every { appComponent.remoteConfigManager() } returns remoteConfigManager + every { remoteConfigManager.identityBridge } returns identityBridge + every { appComponent.sharedPreferencesCache() } returns sharedPreferencesCache + every { appComponent.userInfoService() } returns userInfoService + every { userInfoService.obtainUserId() } returns INITIAL_UID + every { userInfoService.getPartnersIdentityId() } returns null + + // The real product center chain builds a Play Billing client and fires a launch request + // during init; both are irrelevant to the v2 wiring under test. + mockkConstructor(QonversionFactory::class) + every { + anyConstructed().createProductCenterManager( + any(), any(), any(), any(), any(), any(), any(), any(), any(), + ) + } returns mockk(relaxed = true) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `an unconfigured init leaves the identity bridge unbound and identity events no-op`() { + val internalConfig = internalConfig(remoteConfigV2Config = null) + + val qonversion = QonversionInternal(internalConfig, RuntimeEnvironment.getApplication()) + + // The real factory ran and built only the bundled-defaults facade. + val snapshots = qonversion.remoteConfigSnapshots() as QRemoteConfigSnapshotsImpl + assertNull("a dormant init built the Remote Config v2 chain", snapshots.manager) + assertEquals(QRemoteConfigFetchStatus.NotConfigured, fetchBlocking(snapshots).status) + + // Neither v1 identity moment is routed anywhere: the callbacks stay null, and firing the + // bridge — exactly what QRemoteConfigManager does on logout/identify — is a safe no-op. + assertNull(identityBridge.onIdentityScopeChanged) + assertNull(identityBridge.onTargetingInvalidated) + identityBridge.identityScopeChanged() + identityBridge.targetingInvalidated() + + // Dormancy is also storage silence: no v2 key is ever read or written. + verify(exactly = 0) { sharedPreferencesCache.getString(match { it.contains("remote_config_v2") }, any()) } + verify(exactly = 0) { sharedPreferencesCache.putString(match { it.contains("remote_config_v2") }, any()) } + verify(exactly = 0) { + sharedPreferencesCache.updateStringsDurably( + match { values -> values.keys.any { it.contains("remote_config_v2") } }, + any(), + ) + } + } + + @Test + fun `a configured init force-fetches for the boot identity and bridges both identity moments`() { + val manager = configuredManager() + val internalConfig = internalConfig(remoteConfigV2Config = v2Config()) + + val qonversion = QonversionInternal(internalConfig, RuntimeEnvironment.getApplication()) + + // The factory was handed the same config and cache the rest of the SDK uses... + verify(exactly = 1) { + RemoteConfigV2Factory.create( + RuntimeEnvironment.getApplication(), + internalConfig, + sharedPreferencesCache, + any(), + ) + } + // ...its facade is what the public accessor serves... + assertSame(manager, (qonversion.remoteConfigSnapshots() as QRemoteConfigSnapshotsImpl).manager) + // ...and init bound the uid learned from storage with a forced Build fetch, exactly once. + verify(exactly = 1) { manager.updateIdentity(INITIAL_UID, RemoteConfigFetchForceReason.Build) } + + // A v1 identity transition switches the v2 scope with a forced Identify fetch. + identityBridge.identityScopeChanged() + verify(exactly = 1) { manager.updateIdentity(INITIAL_UID, RemoteConfigFetchForceReason.Identify) } + + // The bridge reads the live uid at event time, not the one captured during init. + internalConfig.uid = "next-uid" + identityBridge.identityScopeChanged() + verify(exactly = 1) { manager.updateIdentity("next-uid", RemoteConfigFetchForceReason.Identify) } + + // A targeting invalidation re-reads targeting without a scope transition. + identityBridge.targetingInvalidated() + verify(exactly = 1) { manager.refreshTargeting(null) } + } + + @Test + fun `a throwing v2 manager never breaks the v1 identity moments`() { + val manager = configuredManager() + every { + manager.updateIdentity(any(), RemoteConfigFetchForceReason.Identify) + } throws IllegalStateException("v2 refused the identity change") + every { manager.refreshTargeting(null) } throws IllegalStateException("v2 refused the refresh") + + QonversionInternal(internalConfig(remoteConfigV2Config = v2Config()), RuntimeEnvironment.getApplication()) + + // QRemoteConfigManager fires these on the v1 identity path; an optional subsystem that + // throws must never propagate into it. + identityBridge.identityScopeChanged() + identityBridge.targetingInvalidated() + } + + private fun configuredManager(): RemoteConfigV2Manager { + val manager = mockk(relaxed = true) + mockkObject(RemoteConfigV2Factory) + every { RemoteConfigV2Factory.create(any(), any(), any(), any()) } returns + QRemoteConfigSnapshotsImpl(manager, { null }, MainThreadDispatcher()) + return manager + } + + private fun v2Config() = QRemoteConfigV2Config( + baseUrl = "https://rc.example.invalid/", + environmentUid = "production", + ) + + private fun fetchBlocking(configs: QRemoteConfigSnapshotsImpl): QRemoteConfigFetchResult { + val result = AtomicReference() + configs.fetch( + object : QonversionRemoteConfigFetchCallback { + override fun onResult(result1: QRemoteConfigFetchResult) = result.set(result1) + }, + ) + shadowOf(android.os.Looper.getMainLooper()).idle() + return requireNotNull(result.get()) + } + + private fun internalConfig(remoteConfigV2Config: QRemoteConfigV2Config?) = InternalConfig( + primaryConfig = PrimaryConfig( + projectKey = "project-key", + launchMode = QLaunchMode.SubscriptionManagement, + environment = QEnvironment.Sandbox, + ), + cacheConfig = CacheConfig( + entitlementsCacheLifetime = QEntitlementsCacheLifetime.Month, + fallbackFileIdentifier = null, + ), + remoteConfigV2Config = remoteConfigV2Config, + ) +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/di/module/AppModuleRemoteConfigCacheTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/di/module/AppModuleRemoteConfigCacheTest.kt new file mode 100644 index 000000000..7188db830 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/di/module/AppModuleRemoteConfigCacheTest.kt @@ -0,0 +1,105 @@ +package com.qonversion.android.sdk.internal.di.module + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.qonversion.android.sdk.dto.QEnvironment +import com.qonversion.android.sdk.dto.QLaunchMode +import com.qonversion.android.sdk.dto.QRemoteConfig +import com.qonversion.android.sdk.dto.QRemoteConfigurationAssignmentType +import com.qonversion.android.sdk.dto.QRemoteConfigurationSource +import com.qonversion.android.sdk.dto.QRemoteConfigurationSourceType +import com.qonversion.android.sdk.dto.entitlements.QEntitlementsCacheLifetime +import com.qonversion.android.sdk.internal.InternalConfig +import com.qonversion.android.sdk.internal.dto.config.CacheConfig +import com.qonversion.android.sdk.internal.dto.config.PrimaryConfig +import com.qonversion.android.sdk.internal.provider.AppStateProvider +import com.qonversion.android.sdk.internal.storage.PersistentRemoteConfigCache +import com.qonversion.android.sdk.internal.storage.RemoteConfigCache +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Pins the [AppModule.provideRemoteConfigCache] binding: the graph must serve the persistent + * (v1 last-known-good) implementation, wired to the module's own [InternalConfig] for scoping and + * to the shared preferences cache for storage — with a Moshi that can actually round-trip a + * [QRemoteConfig], which is why the test uses the exact instance [NetworkModule.provideMoshi] + * contributes to the graph. + */ +@RunWith(RobolectricTestRunner::class) +internal class AppModuleRemoteConfigCacheTest { + + private val internalConfig = internalConfig(userId = "user-a") + private val module = AppModule( + ApplicationProvider.getApplicationContext(), + internalConfig, + mockk(), + ) + private val moshi = NetworkModule().provideMoshi() + private val prefsCache = module.provideSharedPreferencesCache( + module.provideSharedPreferences(module.provideApplication()), + ) + + @Test + fun `the graph serves a persistent cache that survives provider recreation`() { + val cache = module.provideRemoteConfigCache(moshi, prefsCache) + assertTrue(cache is PersistentRemoteConfigCache) + + val expected = remoteConfig(contextKey = "paywall", payloadValue = "v1") + saveBlocking(cache, expected) + + // A fresh provider call — the next process, in DI terms — reads the same storage. + val recreated = module.provideRemoteConfigCache(moshi, prefsCache) + assertEquals(expected, recreated.get("paywall")) + assertEquals(listOf(expected), recreated.getAll().remoteConfigs) + } + + @Test + fun `the cache is scoped by the identity of the module's own InternalConfig`() { + val cache = module.provideRemoteConfigCache(moshi, prefsCache) + saveBlocking(cache, remoteConfig(contextKey = "paywall", payloadValue = "user-a")) + + // The provider captured the module's config, not a copy: a uid change re-scopes reads. + internalConfig.uid = "user-b" + assertNull(cache.get("paywall")) + + internalConfig.uid = "user-a" + assertEquals("user-a", cache.get("paywall")?.payload?.get("value")) + } + + private fun saveBlocking(cache: RemoteConfigCache, remoteConfig: QRemoteConfig) { + // The DI-provided cache persists on its own background executor; the completion overload + // is the only way to know the write is durable before asserting on a fresh instance. + val scope = requireNotNull(cache.currentScope()) { "the provided cache derives no scope" } + val persisted = CountDownLatch(1) + cache.save(scope, remoteConfig) { persisted.countDown() } + assertTrue("the provided cache never completed its write", persisted.await(10, TimeUnit.SECONDS)) + } + + private fun internalConfig(userId: String) = InternalConfig( + primaryConfig = PrimaryConfig( + projectKey = "project-key", + launchMode = QLaunchMode.SubscriptionManagement, + environment = QEnvironment.Production, + ), + cacheConfig = CacheConfig(QEntitlementsCacheLifetime.Month, null), + ).also { it.uid = userId } + + private fun remoteConfig(contextKey: String?, payloadValue: String) = QRemoteConfig( + payload = mapOf("value" to payloadValue), + experiment = null, + sourceApi = QRemoteConfigurationSource( + id = "remote-config-id", + name = "Remote Config", + assignmentType = QRemoteConfigurationAssignmentType.Auto, + type = QRemoteConfigurationSourceType.RemoteConfiguration, + contextKeyApi = contextKey, + ), + ) +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/di/module/NetworkModuleTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/di/module/NetworkModuleTest.kt new file mode 100644 index 000000000..1331ac0ff --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/di/module/NetworkModuleTest.kt @@ -0,0 +1,35 @@ +package com.qonversion.android.sdk.internal.di.module + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.qonversion.android.sdk.internal.api.NetworkInterceptor +import io.mockk.mockk +import org.junit.Assert.assertFalse +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.lang.reflect.Proxy +import javax.net.ssl.SSLPeerUnverifiedException +import javax.net.ssl.SSLSession + +@RunWith(RobolectricTestRunner::class) +internal class NetworkModuleTest { + @Test + fun `api client rejects a hostname that does not match the certificate`() { + val application = ApplicationProvider.getApplicationContext() + val interceptor = mockk(relaxed = true) + val sslSession = Proxy.newProxyInstance( + NetworkModuleTest::class.java.classLoader, + arrayOf(SSLSession::class.java), + ) { _, method, _ -> + if (method.name == "getPeerCertificates") { + throw SSLPeerUnverifiedException("no certificate for mismatched host") + } + null + } as SSLSession + + val client = NetworkModule().provideOkHttpClient(application, interceptor) + + assertFalse(client.hostnameVerifier().verify("attacker.invalid", sslSession)) + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/dto/QRemoteConfigurationSourceAssignmentTypeAdapterTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/dto/QRemoteConfigurationSourceAssignmentTypeAdapterTest.kt new file mode 100644 index 000000000..436b339e6 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/dto/QRemoteConfigurationSourceAssignmentTypeAdapterTest.kt @@ -0,0 +1,39 @@ +package com.qonversion.android.sdk.internal.dto + +import com.qonversion.android.sdk.dto.QRemoteConfigurationAssignmentType +import com.squareup.moshi.Moshi +import org.junit.Assert.assertEquals +import org.junit.Test + +internal class QRemoteConfigurationSourceAssignmentTypeAdapterTest { + private val adapter = Moshi.Builder() + .add(QRemoteConfigurationSourceAssignmentTypeAdapter()) + .build() + .adapter(QRemoteConfigurationAssignmentType::class.java) + + @Test + fun `existing assignment ordinals stay stable and frozen is appended`() { + assertEquals( + listOf("Auto", "Manual", "Unknown", "Frozen"), + QRemoteConfigurationAssignmentType.values().map { it.name }, + ) + assertEquals(listOf(0, 1, 2, 3), QRemoteConfigurationAssignmentType.values().map { it.ordinal }) + assertEquals("Frozen", QRemoteConfigurationAssignmentType.fromType("frozen").name) + } + + @Test + fun `frozen assignment has a public enum value and round trips through json`() { + assertEquals(QRemoteConfigurationAssignmentType.Frozen, adapter.fromJson("\"frozen\"")) + assertEquals("\"frozen\"", adapter.toJson(QRemoteConfigurationAssignmentType.Frozen)) + assertEquals( + QRemoteConfigurationAssignmentType.Frozen, + QRemoteConfigurationAssignmentType.fromType("frozen"), + ) + } + + @Test + fun `unknown future assignment remains forward compatible`() { + assertEquals(QRemoteConfigurationAssignmentType.Unknown, adapter.fromJson("\"future-type\"")) + assertEquals(QRemoteConfigurationAssignmentType.Unknown, QRemoteConfigurationAssignmentType.fromType("future-type")) + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/DeviceRemoteConfigClientContextProviderTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/DeviceRemoteConfigClientContextProviderTest.kt new file mode 100644 index 000000000..e716824c7 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/DeviceRemoteConfigClientContextProviderTest.kt @@ -0,0 +1,61 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import android.os.Build +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +/** + * Pins the DEVICE scope of `device_installed_at`. + * + * Robolectric gives a real [android.content.pm.PackageManager] whose `firstInstallTime` can be + * controlled, which is the only way to prove the provider reads the device install date rather + * than anything identity-shaped. + */ +@RunWith(RobolectricTestRunner::class) +internal class DeviceRemoteConfigClientContextProviderTest { + + @Test + fun `device_installed_at is the package first install time in epoch seconds`() { + setFirstInstallTime(1_577_836_800_123) + + val context = requireNotNull(provider().clientContext()) + + assertEquals(1_577_836_800, context.deviceInstalledAtSeconds) + assertEquals("android", context.platform) + assertEquals("9.7.0", context.sdkVersion) + assertEquals(Build.MODEL, context.deviceModel) + assertNotNull(context.locale) + } + + @Test + fun `device_installed_at does not move when the identity does`() { + // The provider is constructed without any identity input, so a logout / identify cycle + // cannot reach it. Two independent instances must agree, and must keep agreeing after the + // SDK would have minted a new anonymous uid. + setFirstInstallTime(1_577_836_800_000) + + val before = requireNotNull(provider().clientContext()).deviceInstalledAtSeconds + val after = requireNotNull(provider().clientContext()).deviceInstalledAtSeconds + + assertEquals(1_577_836_800, before) + assertEquals(before, after) + } + + private fun provider() = DeviceRemoteConfigClientContextProvider( + context = RuntimeEnvironment.getApplication(), + sdkVersion = "9.7.0", + ) + + private fun setFirstInstallTime(millis: Long) { + val application = RuntimeEnvironment.getApplication() + val packageInfo = shadowOf(application.packageManager) + .getInternalMutablePackageInfo(application.packageName) + packageInfo.firstInstallTime = millis + packageInfo.versionName = "1.2.3" + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigFetchPolicyStoreTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigFetchPolicyStoreTest.kt new file mode 100644 index 000000000..f8f1625aa --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigFetchPolicyStoreTest.kt @@ -0,0 +1,82 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.internal.storage.Cache +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class PersistentRemoteConfigFetchPolicyStoreTest { + private val scope = RemoteConfigSnapshotScope("project-secret", "production", "customer-secret") + private val policyScope = RemoteConfigFetchPolicyScope.from(scope) + + @Test + fun `policy state is durably scoped and survives a new store instance`() { + val cache = MapCache() + val first = store(cache) + val state = RemoteConfigFetchPolicyState( + lastSuccessfulFetchAtMillis = 12, + consecutiveRetryableFailures = 3, + nextAllowedFetchAtMillis = 34, + ) + + assertTrue(first.save(policyScope, state)) + val persistedKey = cache.strings.keys.single() + assertFalse(persistedKey.contains("project-secret")) + assertFalse(persistedKey.contains("customer-secret")) + + assertEquals(state, store(cache).load(policyScope)) + } + + @Test + fun `malformed or unbounded persisted policy is removed fail closed`() { + val cache = MapCache() + val policyStore = store(cache) + assertTrue(policyStore.save(policyScope, RemoteConfigFetchPolicyState())) + val persistedKey = cache.strings.keys.single() + cache.strings[persistedKey] = + "{\"version\":1,\"last_successful_fetch_at_millis\":0," + + "\"consecutive_retryable_failures\":999,\"next_allowed_fetch_at_millis\":0}" + + assertNull(policyStore.load(policyScope)) + assertFalse(cache.strings.containsKey(persistedKey)) + } + + private fun store(cache: Cache) = PersistentRemoteConfigFetchPolicyStore( + cache = cache, + moshi = Moshi.Builder().build(), + ) + + private class MapCache : Cache { + val strings = mutableMapOf() + private val longs = mutableMapOf() + private val ints = mutableMapOf() + private val bools = mutableMapOf() + private val floats = mutableMapOf() + + override fun putInt(key: String, value: Int) { ints[key] = value } + override fun getInt(key: String, defValue: Int): Int = ints[key] ?: defValue + override fun getBool(key: String, defValue: Boolean): Boolean = bools[key] ?: defValue + override fun putBool(key: String, value: Boolean) { bools[key] = value } + override fun putFloat(key: String, value: Float) { floats[key] = value } + override fun getFloat(key: String, defValue: Float): Float = floats[key] ?: defValue + override fun putLong(key: String, value: Long) { longs[key] = value } + override fun getLong(key: String, defValue: Long): Long = longs[key] ?: defValue + override fun putString(key: String, value: String?) { strings[key] = value } + override fun getString(key: String, defValue: String?): String? = strings[key] ?: defValue + override fun putObject(key: String, value: T, adapter: JsonAdapter) { + strings[key] = adapter.toJson(value) + } + override fun getObject(key: String, adapter: JsonAdapter): T? = + strings[key]?.let(adapter::fromJson) + override fun remove(key: String) { strings.remove(key) } + override fun updateStringsDurably(values: Map, removedKeys: Set): Boolean { + removedKeys.forEach(strings::remove) + strings.putAll(values) + return true + } + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigSessionStoreTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigSessionStoreTest.kt new file mode 100644 index 000000000..d462ff04d --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigSessionStoreTest.kt @@ -0,0 +1,125 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.internal.storage.Cache +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class PersistentRemoteConfigSessionStoreTest { + private val scope = RemoteConfigSnapshotScope("project-secret", "env-production", "customer-secret") + private val key = RemoteConfigSessionKey(scope, "QON_anon_a") + private val otherIdentity = RemoteConfigSessionKey( + RemoteConfigSnapshotScope("project-secret", "env-production", "other-secret"), + "QON_anon_b", + ) + private val sameScopeNewUid = RemoteConfigSessionKey(scope, "QON_anon_c") + private val session = RemoteConfigGatewaySession( + token = "qrcs1.session-secret", + projectId = 42, + environment = "prod", + expiresAtMillis = 1_800_000_000_000, + ) + + @Test + fun `session is durably scoped and survives a new store instance`() { + val cache = MapCache() + assertTrue(store(cache).save(key, session)) + + val persistedKey = cache.strings.keys.single() + assertFalse(persistedKey.contains("project-secret")) + assertFalse(persistedKey.contains("customer-secret")) + assertFalse(persistedKey.contains("QON_anon_a")) + assertEquals(session, store(cache).load(key)) + } + + @Test + fun `an identity change addresses a different record and never reads the previous token`() { + val cache = MapCache() + assertTrue(store(cache).save(key, session)) + + assertNull(store(cache).load(otherIdentity)) + assertTrue(store(cache).save(otherIdentity, session.copy(token = "qrcs1.other"))) + assertEquals(2, cache.strings.size) + assertEquals("qrcs1.session-secret", store(cache).load(key)?.token) + assertEquals("qrcs1.other", store(cache).load(otherIdentity)?.token) + } + + @Test + fun `a re-minted anonymous uid under the same scope addresses a different record`() { + // The scope's canonical user id can stay put while the SDK mints a new anonymous uid; the + // session was issued for the uid, so it must not be replayed for the new one. + val cache = MapCache() + assertTrue(store(cache).save(key, session)) + + assertNull(store(cache).load(sameScopeNewUid)) + assertEquals(session.token, store(cache).load(key)?.token) + } + + @Test + fun `clearing drops only the addressed identity`() { + val cache = MapCache() + val sessionStore = store(cache) + assertTrue(sessionStore.save(key, session)) + assertTrue(sessionStore.save(otherIdentity, session.copy(token = "qrcs1.other"))) + + assertTrue(sessionStore.clear(key)) + + assertNull(sessionStore.load(key)) + assertEquals("qrcs1.other", sessionStore.load(otherIdentity)?.token) + } + + @Test + fun `malformed persisted session is removed fail closed`() { + val cache = MapCache() + val sessionStore = store(cache) + assertTrue(sessionStore.save(key, session)) + val persistedKey = cache.strings.keys.single() + cache.strings[persistedKey] = + "{\"version\":1,\"session_token\":\"\",\"project_id\":42," + + "\"environment\":\"prod\",\"expires_at_millis\":1}" + + assertNull(sessionStore.load(key)) + assertFalse(cache.strings.containsKey(persistedKey)) + } + + @Test + fun `a session that cannot be described is refused rather than half written`() { + val cache = MapCache() + + assertFalse(store(cache).save(key, session.copy(token = ""))) + assertFalse(store(cache).save(key, session.copy(projectId = 0))) + assertFalse(store(cache).save(key, session.copy(expiresAtMillis = 0))) + assertTrue(cache.strings.isEmpty()) + } + + private fun store(cache: Cache) = PersistentRemoteConfigSessionStore(cache, Moshi.Builder().build()) + + private class MapCache : Cache { + val strings = mutableMapOf() + + override fun putInt(key: String, value: Int) = Unit + override fun getInt(key: String, defValue: Int): Int = defValue + override fun getBool(key: String, defValue: Boolean): Boolean = defValue + override fun putBool(key: String, value: Boolean) = Unit + override fun putFloat(key: String, value: Float) = Unit + override fun getFloat(key: String, defValue: Float): Float = defValue + override fun putLong(key: String, value: Long) = Unit + override fun getLong(key: String, defValue: Long): Long = defValue + override fun putString(key: String, value: String?) { strings[key] = value } + override fun getString(key: String, defValue: String?): String? = strings[key] ?: defValue + override fun remove(key: String) { strings.remove(key) } + + override fun updateStringsDurably(values: Map, removedKeys: Set): Boolean { + removedKeys.forEach(strings::remove) + strings.putAll(values) + return true + } + + override fun putObject(key: String, value: T, adapter: JsonAdapter) = Unit + override fun getObject(key: String, adapter: JsonAdapter): T? = null + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigTelemetryStoreTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigTelemetryStoreTest.kt new file mode 100644 index 000000000..c4a13899f --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigTelemetryStoreTest.kt @@ -0,0 +1,211 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.internal.storage.Cache +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +private const val OCCURRED_AT_SECONDS = 1_700_000_000L +private const val RELEASE_7 = 7L +private const val STORE_BYTE_BUDGET = REMOTE_CONFIG_TELEMETRY_MAX_BYTES +private const val TINY_BUDGET = 4 * 1024 + +/** + * The durable telemetry buffer: what survives a process, what is refused, and what the byte budget + * does to an over-sized buffer. + */ +internal class PersistentRemoteConfigTelemetryStoreTest { + private val scope = RemoteConfigSnapshotScope("project-secret", "env-production", "customer-secret") + private val otherScope = RemoteConfigSnapshotScope("project-secret", "env-production", "other-secret") + + @Test + fun `a buffer round-trips through a new store instance`() { + val cache = MapCache() + val events = listOf(decodeFailure("paywall_prices"), guardEvent()) + + assertTrue(store(cache).save(scope, events)) + + val persistedKey = cache.strings.keys.single() + // The storage key is a salted digest: no identifier ever lands in a preference name. + assertFalse(persistedKey.contains("project-secret")) + assertFalse(persistedKey.contains("customer-secret")) + assertEquals(events, store(cache).load(scope)) + } + + @Test + fun `each identity addresses its own buffer`() { + val cache = MapCache() + val telemetryStore = store(cache) + assertTrue(telemetryStore.save(scope, listOf(decodeFailure("paywall_prices")))) + assertTrue(telemetryStore.save(otherScope, listOf(decodeFailure("onboarding")))) + + assertEquals(2, cache.strings.size) + assertEquals("paywall_prices", telemetryStore.load(scope).single().logicalKey) + assertEquals("onboarding", telemetryStore.load(otherScope).single().logicalKey) + } + + @Test + fun `clearing drops only the addressed identity`() { + val cache = MapCache() + val telemetryStore = store(cache) + telemetryStore.save(scope, listOf(decodeFailure("paywall_prices"))) + telemetryStore.save(otherScope, listOf(decodeFailure("onboarding"))) + + assertTrue(telemetryStore.clear(scope)) + + assertTrue(telemetryStore.load(scope).isEmpty()) + assertEquals(1, telemetryStore.load(otherScope).size) + } + + @Test + fun `an empty buffer clears the record instead of writing one`() { + val cache = MapCache() + val telemetryStore = store(cache) + telemetryStore.save(scope, listOf(decodeFailure("paywall_prices"))) + + assertTrue(telemetryStore.save(scope, emptyList())) + + assertTrue(cache.strings.isEmpty()) + } + + @Test + fun `the worst-case buffer fits the byte budget and round-trips whole`() { + val cache = MapCache() + // Every bucket the sender can hold, each with a logical key at the 200-byte contract + // maximum: this is the largest record the store can ever be asked to write. + val events = (0 until REMOTE_CONFIG_TELEMETRY_MAX_ENTRIES).map { index -> + decodeFailure(index.toString().padStart(REMOTE_CONFIG_TELEMETRY_LOGICAL_KEY_MAX_BYTES, 'k')) + } + + assertTrue(store(cache).save(scope, events)) + + val raw = requireNotNull(cache.strings.values.single()) + assertTrue("the byte budget was exceeded", raw.toByteArray(Charsets.UTF_8).size <= STORE_BYTE_BUDGET) + // Nothing had to be shrunk away, so a restart resumes the whole buffer. + assertEquals(events, store(cache).load(scope)) + } + + @Test + fun `a buffer over the byte budget is shrunk rather than refused`() { + val cache = MapCache() + val events = (0 until REMOTE_CONFIG_TELEMETRY_MAX_ENTRIES).map { index -> + decodeFailure(index.toString().padStart(REMOTE_CONFIG_TELEMETRY_LOGICAL_KEY_MAX_BYTES, 'k')) + } + // A budget the shipped one is comfortably above, so the shrinking path is exercised rather + // than assumed: partial telemetry beats none. + val tinyStore = PersistentRemoteConfigTelemetryStore(cache, Moshi.Builder().build(), maxBytes = TINY_BUDGET) + + assertTrue(tinyStore.save(scope, events)) + + val raw = requireNotNull(cache.strings.values.single()) + assertTrue("the byte budget was exceeded", raw.toByteArray(Charsets.UTF_8).size <= TINY_BUDGET) + val loaded = tinyStore.load(scope) + assertTrue(loaded.isNotEmpty()) + assertTrue(loaded.size < events.size) + // The oldest buckets are the ones kept: they are what the next flush would have sent first. + assertEquals(events.take(loaded.size), loaded) + } + + @Test + fun `the entry bound is the sender's, not a second hardcoded one`() { + val cache = MapCache() + // A store that capped at its own constant would silently disagree with a sender configured + // for a different bound — and the disagreement would only ever show up as lost events. + val smallStore = PersistentRemoteConfigTelemetryStore(cache, Moshi.Builder().build(), maxEntries = 3) + val events = (0 until 10).map { decodeFailure("key-$it") } + + assertTrue(smallStore.save(scope, events)) + + assertEquals(events.take(3), smallStore.load(scope)) + } + + @Test + fun `an event the gateway would refuse is never persisted`() { + val cache = MapCache() + + val refused = listOf( + decodeFailure("k".repeat(REMOTE_CONFIG_TELEMETRY_LOGICAL_KEY_MAX_BYTES + 1)), + decodeFailure(""), + // A non-decode kind may not name a key. + guardEvent().copy(logicalKey = "paywall_prices"), + decodeFailure("paywall_prices").copy(count = 0), + decodeFailure("paywall_prices").copy(lastOccurredAtSeconds = 0), + decodeFailure("paywall_prices").copy(releaseNumber = -1), + ) + + assertTrue(store(cache).save(scope, refused)) + assertTrue(cache.strings.isEmpty()) + } + + @Test + fun `a malformed persisted buffer is dropped fail closed`() { + val cache = MapCache() + val telemetryStore = store(cache) + telemetryStore.save(scope, listOf(decodeFailure("paywall_prices"))) + val persistedKey = cache.strings.keys.single() + cache.strings[persistedKey] = "{\"version\":99,\"events\":[]}" + + assertTrue(telemetryStore.load(scope).isEmpty()) + assertFalse(cache.strings.containsKey(persistedKey)) + } + + @Test + fun `an unknown persisted kind is skipped rather than replayed`() { + val cache = MapCache() + val telemetryStore = store(cache) + telemetryStore.save(scope, listOf(decodeFailure("paywall_prices"))) + val persistedKey = cache.strings.keys.single() + cache.strings[persistedKey] = + "{\"version\":1,\"events\":[{\"kind\":\"from_the_future\",\"logical_key\":\"x\"," + + "\"release_number\":1,\"count\":1,\"last_occurred_at\":$OCCURRED_AT_SECONDS}]}" + + // Sending it back would cost the whole batch a terminal 400. + assertTrue(telemetryStore.load(scope).isEmpty()) + } + + private fun decodeFailure(key: String) = RemoteConfigTelemetryEvent( + kind = RemoteConfigTelemetryKind.DecodeFailure, + logicalKey = key, + releaseNumber = RELEASE_7, + count = 1, + lastOccurredAtSeconds = OCCURRED_AT_SECONDS, + ) + + private fun guardEvent() = RemoteConfigTelemetryEvent( + kind = RemoteConfigTelemetryKind.PreloadCorrupt, + logicalKey = "", + releaseNumber = 0, + count = 3, + lastOccurredAtSeconds = OCCURRED_AT_SECONDS, + ) + + private fun store(cache: Cache) = PersistentRemoteConfigTelemetryStore(cache, Moshi.Builder().build()) + + private class MapCache : Cache { + val strings = mutableMapOf() + + override fun putInt(key: String, value: Int) = Unit + override fun getInt(key: String, defValue: Int): Int = defValue + override fun getBool(key: String, defValue: Boolean): Boolean = defValue + override fun putBool(key: String, value: Boolean) = Unit + override fun putFloat(key: String, value: Float) = Unit + override fun getFloat(key: String, defValue: Float): Float = defValue + override fun putLong(key: String, value: Long) = Unit + override fun getLong(key: String, defValue: Long): Long = defValue + override fun putString(key: String, value: String?) { strings[key] = value } + override fun getString(key: String, defValue: String?): String? = strings[key] ?: defValue + override fun remove(key: String) { strings.remove(key) } + + override fun updateStringsDurably(values: Map, removedKeys: Set): Boolean { + removedKeys.forEach(strings::remove) + strings.putAll(values) + return true + } + + override fun putObject(key: String, value: T, adapter: JsonAdapter) = Unit + override fun getObject(key: String, adapter: JsonAdapter): T? = null + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigsPublicApiTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigsPublicApiTest.kt new file mode 100644 index 000000000..91dac4bd4 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigsPublicApiTest.kt @@ -0,0 +1,593 @@ +@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.QRemoteConfigApplyPolicy +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigDecoder +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchResult +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchStatus +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSource +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigUpdate +import com.qonversion.android.sdk.listeners.QonversionRemoteConfigActivationCallback +import com.qonversion.android.sdk.listeners.QonversionRemoteConfigFetchCallback +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * Contract tests for the public Remote Config v2 surface, driven end to end: real snapshot core, + * real read guard, real fetch coordinator, real HTTP. + */ +internal class QRemoteConfigsPublicApiTest { + private val harnesses = mutableListOf() + + @After + fun tearDown() { + harnesses.forEach { it.shutdown() } + } + + @Test + fun `fetch times out with the best available snapshot while the request keeps running`() { + val harness = harness() + harness.delaySnapshotReads(RESPONSE_DELAY_MILLIS) + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + + val latch = CountDownLatch(1) + val result = AtomicReference() + harness.manager.fetch(FETCH_TIMEOUT_MILLIS) { fetchResult -> + result.set(fetchResult) + latch.countDown() + } + awaitScheduledTimeout(harness) + + assertTrue("timeout was not delivered", latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(listOf(FETCH_TIMEOUT_MILLIS), harness.timeoutScheduler.requestedDelays) + val timedOut = requireNotNull(result.get()) + assertEquals(QRemoteConfigFetchStatus.TimedOut, timedOut.status) + // Best available at timeout time: nothing was admitted yet, so the ladder is at fallback. + val fallback = requireNotNull(timedOut.snapshot.rawValue("count")) + assertEquals(QRemoteConfigSource.Fallback, fallback.source) + assertEquals("0", fallback.value) + + // The request was not cancelled — its release is still admitted and activates normally. + harness.awaitCandidate(releaseNumber = 1) + assertTrue(harness.activateBlocking().changed) + assertEquals("1", harness.configs.current.rawValue("count")?.value) + } + + @Test + fun `fetch without an explicit timeout uses the configured default`() { + val harness = harness(defaultFetchTimeoutMillis = FETCH_TIMEOUT_MILLIS) + harness.hangSnapshotReads(true) + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + + val latch = CountDownLatch(1) + val result = AtomicReference() + harness.manager.fetch(null) { fetchResult -> + result.set(fetchResult) + latch.countDown() + } + awaitScheduledTimeout(harness) + + assertTrue(latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(QRemoteConfigFetchStatus.TimedOut, requireNotNull(result.get()).status) + // The default is what was scheduled — a hard-coded or ignored timeout would show up here. + assertEquals(listOf(FETCH_TIMEOUT_MILLIS), harness.timeoutScheduler.requestedDelays) + } + + @Test + fun `activate reports a change only when the activated release differs`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + + assertEquals(QRemoteConfigFetchStatus.Fetched, harness.fetchBlocking().status) + val first = harness.activateBlocking() + val second = harness.activateBlocking() + + assertTrue("the first activation must be a change", first.changed) + assertFalse("re-activating the same release changes nothing", second.changed) + assertEquals("release-1", first.snapshot.releaseUid) + assertNull(first.fetchStatus) + } + + @Test + fun `fetchAndActivate carries the fetch status into the activation result`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + + val latch = CountDownLatch(1) + val result = AtomicReference() + // Through the public facade: this overload must not delegate to itself. + harness.configs.fetchAndActivate { activation -> + result.set(activation) + latch.countDown() + } + + assertTrue(latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + val activation = requireNotNull(result.get()) + assertEquals(QRemoteConfigFetchStatus.Fetched, activation.fetchStatus) + assertTrue(activation.changed) + assertEquals("1", activation.snapshot.rawValue("count")?.value) + } + + @Test + fun `every public fetch and activate overload completes exactly once`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + + val fetched = awaitFetch(harness) { callback -> harness.configs.fetch(callback) } + assertEquals(QRemoteConfigFetchStatus.Fetched, fetched.status) + val fetchedWithTimeout = awaitFetch(harness) { callback -> + harness.configs.fetch(TIMEOUT_UNUSED, callback) + } + assertEquals(QRemoteConfigFetchStatus.Fetched, fetchedWithTimeout.status) + + val activated = awaitActivation(harness) { callback -> harness.configs.activate(callback) } + assertTrue(activated.changed) + assertNull(activated.fetchStatus) + val reActivated = awaitActivation(harness) { callback -> + harness.configs.fetchAndActivate(TIMEOUT_UNUSED, callback) + } + assertFalse(reActivated.changed) + assertEquals(QRemoteConfigFetchStatus.Fetched, reActivated.fetchStatus) + } + + @Test + fun `a server error is reported as a failed fetch`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.serveStatus(HTTP_SERVER_ERROR) + + assertEquals(QRemoteConfigFetchStatus.Failed, harness.fetchBlocking().status) + } + + @Test + fun `an unchanged release is reported as not modified`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.serveStatus(HTTP_NOT_MODIFIED) + + assertEquals(QRemoteConfigFetchStatus.NotModified, harness.fetchBlocking().status) + } + + @Test + fun `a fetch inside the minimum interval is throttled`() { + val harness = RemoteConfigV2Harness(minimumFetchIntervalMillis = THROTTLE_INTERVAL_MILLIS) + .also { harnesses += it } + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + + assertEquals(QRemoteConfigFetchStatus.Throttled, harness.fetchBlocking().status) + } + + @Test + fun `a rotated targeting context keeps being served, it is not an identity signal`() { + // The gateway recomputes the fingerprint from mutable inputs — app/OS version, locale, + // purchases, properties, experiment enrollment — so it changes for the same identity all + // the time. Refusing the new value would freeze this user's config until logout. + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + assertEquals(QRemoteConfigFetchStatus.Fetched, harness.fetchBlocking().status) + harness.activateBlocking() + + harness.rotateContextFingerprint() + + assertEquals(QRemoteConfigFetchStatus.Fetched, harness.fetchBlocking().status) + harness.activateBlocking() + val served = requireNotNull(harness.configs.current.rawValue("count")) + assertEquals(QRemoteConfigSource.Server, served.source) + assertEquals("2", served.value) + assertEquals("release-rotated", harness.core.lastFetchedSnapshot()?.releaseUid) + } + + @Test + fun `a key without metadata reports no metadata rather than the JSON literal`() { + val harness = harness() + harness.serve( + "release-1", + 1, + listOf( + RcWireValue("count", "1"), + RcWireValue("annotated", "2", metadata = "{\"reload\":true}"), + ), + ) + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + + val snapshot = harness.configs.current + assertNull(snapshot.rawValue("count")?.metadataJson) + assertEquals("{\"reload\":true}", snapshot.rawValue("annotated")?.metadataJson) + } + + @Test + fun `a decoder that throws rejects the value like one that returns null`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + + val thrown = harness.configs.current.value("count") { error("decoder blew up") } + + // No prior release to fall back to and no bundled value the decoder accepts, so the read + // resolves to nothing instead of propagating the failure to the caller. + assertNull(thrown) + } + + @Test + fun `reads report every ladder position with its value`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + + val server = requireNotNull(harness.configs.current.value("count", INT_DECODER)) + assertEquals(QRemoteConfigSource.Server, server.source) + assertEquals(1, server.value) + + val fallback = requireNotNull(harness.configs.current.value("bundled_only", STRING_DECODER)) + assertEquals(QRemoteConfigSource.Fallback, fallback.source) + assertEquals("\"bundled\"", fallback.value) + + // A release whose value the caller's decoder rejects falls back to the previously + // activated one — that is the cache position, and only a typed read can observe it. + harness.serve("release-2", 2, listOf(RcWireValue("count", "\"not-a-number\""))) + harness.fetchBlocking() + harness.activateBlocking() + + val cached = requireNotNull(harness.configs.current.value("count", INT_DECODER)) + assertEquals(QRemoteConfigSource.Cache, cached.source) + assertEquals(1, cached.value) + } + + @Test + fun `raw reads stay opaque while typed reads apply the decoder`() { + val harness = harness() + harness.serve("release-1", 1, listOf(RcWireValue("count", "{\"nested\":[1,2]}"))) + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + + val snapshot = harness.configs.current + val raw = requireNotNull(snapshot.rawValue("count")) + assertEquals("{\"nested\":[1,2]}", raw.value) + assertEquals(QRemoteConfigSource.Server, raw.source) + assertEquals(QRemoteConfigApplyPolicy.OnNextActivate, raw.applyPolicy) + assertEquals("var-count-on_next_activate", raw.variationUid) + + val json = requireNotNull(snapshot.jsonValue("count")) + @Suppress("UNCHECKED_CAST") + val nested = (json.value as Map)["nested"] as List + assertEquals(listOf(1.0, 2.0), nested) + + val typed = requireNotNull(snapshot.value("count", QRemoteConfigDecoder { rawJson -> rawJson.length })) + assertEquals("{\"nested\":[1,2]}".length, typed.value) + assertNull(snapshot.rawValue("unknown-key")) + assertEquals(setOf("count", "bundled_only"), snapshot.contextKeys) + } + + @Test + fun `context keys exclude deleted server values which have no bundled fallback`() { + val harness = harness() + harness.serve( + "release-1", + 1, + listOf(RcWireValue("count", "1"), RcWireValue("server_only", "true")), + ) + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + assertEquals(setOf("count", "server_only", "bundled_only"), harness.configs.current.contextKeys) + + // A complete snapshot omitting server_only tombstones it so Previous cannot resurrect it. + // The public key set promises readable keys, therefore the internal tombstone must not leak. + harness.serve( + "release-2", + 2, + listOf(RcWireValue("count", "2", applyPolicy = "immediate")), + ) + harness.fetchBlocking() + + val current = harness.configs.current + assertNull(current.rawValue("server_only")) + assertEquals(setOf("count", "bundled_only"), current.contextKeys) + } + + @Test + fun `bundled fallback values answer before any fetch or activation`() { + val harness = harness() + + // No identity, no fetch, no activation: the bundled getter is a pure asset read. + assertEquals("bundled", harness.configs.fallbackRemoteConfigValue("bundled_only")?.rawValue) + assertEquals(0.0, harness.configs.fallbackRemoteConfigValue("count")?.rawValue) + assertNull(harness.configs.fallbackRemoteConfigValue("unknown-key")) + + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.awaitWorkerIdle() + val preActivate = requireNotNull(harness.configs.current.rawValue("count")) + assertEquals(QRemoteConfigSource.Fallback, preActivate.source) + } + + @Test + fun `reading before activate is reported in a debug build`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.awaitWorkerIdle() + + harness.configs.current + harness.configs.current + + assertEquals(listOf(REMOTE_CONFIG_READ_BEFORE_ACTIVATE_MESSAGE), harness.assertions) + assertTrue(harness.guardEvents.contains(RemoteConfigReadGuardEvent.ReadBeforeActivate)) + } + + @Test + fun `a release build silently activates once on the first read`() { + val harness = harness(buildMode = RemoteConfigReadBuildMode.Release) + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + + val implicitlyActivated = requireNotNull(harness.configs.current.rawValue("count")) + + assertEquals(QRemoteConfigSource.Server, implicitlyActivated.source) + assertEquals("1", implicitlyActivated.value) + assertTrue(harness.assertions.isEmpty()) + assertTrue(harness.guardEvents.contains(RemoteConfigReadGuardEvent.ImplicitActivation)) + } + + @Test + fun `an immediate release activates the whole release and reaches subscribers`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + harness.awaitAcks(1) + + val updates = Collections.synchronizedList(mutableListOf()) + val latch = CountDownLatch(1) + harness.subscribeCollecting(updates, latch) + harness.serve( + "release-2", + 2, + listOf( + RcWireValue("count", "5", applyPolicy = "immediate", metadata = "{\"reload\":true}"), + RcWireValue("extra", "\"new\""), + ), + ) + harness.fetchBlocking() + harness.awaitAcks(2) + + assertTrue("no update was delivered", latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + val update = updates.single() + assertEquals(setOf("count", "extra"), update.changedKeys) + assertEquals(QRemoteConfigApplyPolicy.Immediate, update.applyPolicy("count")) + assertEquals("{\"reload\":true}", update.metadataJson("count")) + // The whole release was swapped, not just the immediate key. + assertEquals("5", harness.configs.current.rawValue("count")?.value) + assertEquals("\"new\"", harness.configs.current.rawValue("extra")?.value) + assertEquals("release-2", update.snapshot.releaseUid) + assertTrue(harness.ackRequests.last().body.contains("\"release_number\":2")) + } + + @Test + fun `a removed subscription stops receiving updates`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + + val updates = Collections.synchronizedList(mutableListOf()) + val subscription = harness.subscribeCollecting(updates, CountDownLatch(1)) + subscription.remove() + subscription.remove() + + harness.serve("release-2", 2, listOf(RcWireValue("count", "9"))) + harness.fetchBlocking() + harness.activateBlocking() + + assertEquals("9", harness.configs.current.rawValue("count")?.value) + assertTrue(updates.isEmpty()) + } + + @Test + fun `refreshing targeting re-fetches without dropping the served release`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + harness.serve("release-2", 2, listOf(RcWireValue("count", "2"))) + + harness.manager.refreshTargeting() + harness.awaitCandidate(releaseNumber = 2) + + // Same identity: the served release must keep serving until the app activates the new one. + assertEquals("1", harness.configs.current.rawValue("count")?.value) + assertTrue(harness.activateBlocking().changed) + assertEquals("2", harness.configs.current.rawValue("count")?.value) + } + + @Test + fun `an identity switch hides the previous snapshot and forces a fresh fetch`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + assertEquals("1", harness.configs.current.rawValue("count")?.value) + + harness.serve("release-9", 9, listOf(RcWireValue("count", "9"))) + harness.identify("QON_anon_b", "canonical-b", RemoteConfigFetchForceReason.Logout) + + // Decision A: the previous identity's release is unreadable the instant the scope switches, + // without waiting for any background work. + val afterSwitch = requireNotNull(harness.configs.current.rawValue("count")) + assertEquals(QRemoteConfigSource.Fallback, afterSwitch.source) + assertEquals("0", afterSwitch.value) + + harness.awaitCandidate(releaseNumber = 9) + harness.activateBlocking() + assertEquals("9", harness.configs.current.rawValue("count")?.value) + + // The old identity's snapshot is still stored under its own scope and never leaks. + val scopeA = RemoteConfigSnapshotScope(RC_PROJECT_KEY, RC_ENVIRONMENT, "canonical-a") + val scopeB = RemoteConfigSnapshotScope(RC_PROJECT_KEY, RC_ENVIRONMENT, "canonical-b") + assertEquals(1L, harness.snapshotStore.states[scopeA]?.active?.releaseNumber) + assertEquals(9L, harness.snapshotStore.states[scopeB]?.active?.releaseNumber) + // A session is minted per identity: the new uid never replays the previous token. + assertTrue(harness.sessionRequests.size >= 2) + assertTrue(harness.sessionRequests.any { it.contains("QON_anon_a") }) + assertTrue(harness.sessionRequests.any { it.contains("QON_anon_b") }) + } + + @Test + fun `the client context is re-sent for every identity`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.identify("QON_anon_b", "canonical-b", RemoteConfigFetchForceReason.Logout) + awaitSnapshotReads(harness, count = 2) + + val installDates = harness.snapshotRequests.map { body -> + Regex("\"device_installed_at\":(\\d+)").find(body)?.groupValues?.get(1) + } + assertTrue("expected snapshot reads for both identities", installDates.size >= 2) + // That this value is genuinely device-scoped (rather than a constant supplied by this + // harness) is proven against a real PackageManager in RemoteConfigV2DeviceScopeTest. + assertEquals(setOf(RC_DEVICE_INSTALLED_AT.toString()), installDates.toSet()) + } + + @Test + fun `every completion is delivered on the main thread`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + val threads = Collections.synchronizedList(mutableListOf()) + val updates = Collections.synchronizedList(mutableListOf()) + val updateLatch = CountDownLatch(1) + harness.manager.subscribeOnConfigUpdate { update -> + threads += Thread.currentThread().name + updates += update + updateLatch.countDown() + } + + // fetchBlocking / activateBlocking assert the callback thread internally. + harness.fetchBlocking() + harness.activateBlocking() + + assertTrue(updateLatch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(listOf(RC_MAIN_THREAD_NAME), threads) + assertEquals(1, updates.size) + } + + @Test + fun `a dormant configuration answers NotConfigured and still serves bundled defaults`() { + val dormant = QRemoteConfigSnapshotsImpl( + manager = null, + bundledValueReader = { contextKey -> + if (contextKey == "bundled_only") { + com.qonversion.android.sdk.dto.QRemoteConfigFallbackValue("bundled") + } else { + null + } + }, + mainDispatcher = { action -> action() }, + ) + + val fetchResult = AtomicReference() + dormant.fetch { result -> fetchResult.set(result) } + val activationResult = AtomicReference() + dormant.fetchAndActivate(TIMEOUT_UNUSED) { result -> activationResult.set(result) } + + assertEquals(QRemoteConfigFetchStatus.NotConfigured, requireNotNull(fetchResult.get()).status) + assertEquals( + QRemoteConfigFetchStatus.NotConfigured, + requireNotNull(activationResult.get()).fetchStatus, + ) + assertFalse(requireNotNull(activationResult.get()).changed) + assertTrue(dormant.current.contextKeys.isEmpty()) + assertNull(dormant.current.rawValue("count")) + assertEquals("bundled", dormant.fallbackRemoteConfigValue("bundled_only")?.rawValue) + val updates = Collections.synchronizedList(mutableListOf()) + val subscription = dormant.subscribeOnConfigUpdate { update -> updates += update } + subscription.remove() + subscription.remove() + assertTrue(updates.isEmpty()) + } + + private fun awaitFetch( + harness: RemoteConfigV2Harness, + call: (QonversionRemoteConfigFetchCallback) -> Unit, + ): QRemoteConfigFetchResult { + val latch = CountDownLatch(1) + val results = Collections.synchronizedList(mutableListOf()) + call( + QonversionRemoteConfigFetchCallback { result -> + results += result + latch.countDown() + }, + ) + assertTrue(latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(1, results.size) + return results.single() + } + + private fun awaitActivation( + harness: RemoteConfigV2Harness, + call: (QonversionRemoteConfigActivationCallback) -> Unit, + ): QRemoteConfigActivationResult { + val latch = CountDownLatch(1) + val results = Collections.synchronizedList(mutableListOf()) + call( + QonversionRemoteConfigActivationCallback { result -> + results += result + latch.countDown() + }, + ) + assertTrue(latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(1, results.size) + return results.single() + } + + private fun harness( + buildMode: RemoteConfigReadBuildMode = RemoteConfigReadBuildMode.Debug, + defaultFetchTimeoutMillis: Long = 0, + ) = RemoteConfigV2Harness( + buildMode = buildMode, + defaultFetchTimeoutMillis = defaultFetchTimeoutMillis, + ).also { harnesses += it } + + private fun awaitSnapshotReads(harness: RemoteConfigV2Harness, count: Int) { + val deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(RC_AWAIT_SECONDS) + while (harness.snapshotRequests.size < count && System.currentTimeMillis() < deadline) { + Thread.sleep(POLL_INTERVAL_MILLIS) + } + } + + private fun awaitScheduledTimeout(harness: RemoteConfigV2Harness) { + val deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(RC_AWAIT_SECONDS) + while (harness.timeoutScheduler.pendingCount() == 0 && System.currentTimeMillis() < deadline) { + Thread.sleep(POLL_INTERVAL_MILLIS) + } + harness.timeoutScheduler.runAll() + } + + private companion object { + const val FETCH_TIMEOUT_MILLIS = 50L + const val RESPONSE_DELAY_MILLIS = 2_000L + const val POLL_INTERVAL_MILLIS = 10L + const val TIMEOUT_UNUSED = 5_000L + const val THROTTLE_INTERVAL_MILLIS = 600_000L + + val INT_DECODER = QRemoteConfigDecoder { rawJson -> rawJson.toIntOrNull() } + val STRING_DECODER = QRemoteConfigDecoder { rawJson -> rawJson } + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigActivationAckIntegrationTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigActivationAckIntegrationTest.kt new file mode 100644 index 000000000..21c3c3489 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigActivationAckIntegrationTest.kt @@ -0,0 +1,173 @@ +@file:OptIn(ExperimentalQonversionApi::class) + +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.TimeUnit + +/** + * The activation ack as the shipped chain produces it: real snapshot core, real read guard, real + * fetch coordinator, real transport, real HTTP. + * + * These tests are about *when* an ack is owed and what it may never cost. The wire contract and the + * failure ladder are proven in [RemoteConfigActivationAckTest]. + */ +internal class RemoteConfigActivationAckIntegrationTest { + private val harnesses = mutableListOf() + + @After + fun tearDown() { + harnesses.forEach { it.shutdown() } + } + + @Test + fun `an explicit activation is acked exactly once`() { + val harness = harness() + harness.identify("QON_anon_a", CANONICAL_A, RemoteConfigFetchForceReason.Build) + harness.awaitCandidate(releaseNumber = 1) + + assertTrue(harness.activateBlocking().changed) + harness.awaitAcks(1) + + val ack = harness.ackRequests.single() + assertEquals("Bearer project-token", ack.authorization) + // The very session the snapshot was read under — the ack rides the same credential. + assertEquals("qrcs1.session-1", ack.sessionHeader) + assertTrue("unexpected ack body: ${ack.body}", ack.body.startsWith("{\"release_number\":1,\"activated_at\":")) + + // Re-activating the same release owes nothing, however often it is asked for. + harness.activateBlocking() + harness.activateBlocking() + harness.awaitWorkerIdle() + assertEquals(1, harness.ackRequests.size) + } + + @Test + fun `activating a newer release acks it and never re-acks the old one`() { + val harness = harness() + harness.identify("QON_anon_a", CANONICAL_A, RemoteConfigFetchForceReason.Build) + harness.awaitCandidate(releaseNumber = 1) + harness.activateBlocking() + harness.awaitAcks(1) + + harness.serve("release-2", RELEASE_2, listOf(RcWireValue("count", "2"))) + harness.fetchBlocking() + harness.awaitCandidate(releaseNumber = RELEASE_2) + assertTrue(harness.activateBlocking().changed) + harness.awaitAcks(2) + + assertEquals( + listOf(1L, RELEASE_2), + harness.ackRequests.map { requireNotNull(RELEASE_NUMBER.find(it.body)).groupValues[1].toLong() }, + ) + } + + @Test + fun `an activation never waits on the ack`() { + val harness = harness() + // The gateway accepts the ack and never answers it. + harness.hangAckReads(true) + harness.identify("QON_anon_a", CANONICAL_A, RemoteConfigFetchForceReason.Build) + harness.awaitCandidate(releaseNumber = 1) + + // Would time out inside activateBlocking() if activation joined the ack in any way. + assertTrue(harness.activateBlocking().changed) + harness.awaitAcks(1) + + // ...and the activation path itself issues no further request: the wedged ack neither + // blocks nor re-arms anything, and a second activation of the same release is silent. + assertEquals(1, harness.ackRequests.size) + harness.activateBlocking() + harness.awaitWorkerIdle() + assertEquals(1, harness.ackRequests.size) + } + + @Test + fun `a read that implicitly activates is acked too`() { + // Release builds activate on the first read instead of asserting; that activation changes + // the served release exactly as an explicit one does. + val harness = harness(buildMode = RemoteConfigReadBuildMode.Release) + harness.identify("QON_anon_a", CANONICAL_A, RemoteConfigFetchForceReason.Build) + harness.awaitCandidate(releaseNumber = 1) + + assertEquals("1", harness.configs.current.rawValue("count")?.value) + harness.awaitAcks(1) + + assertTrue(harness.ackRequests.single().body.startsWith("{\"release_number\":1,")) + // The explicit activate() that follows reports Unchanged and must not ack again. + harness.activateBlocking() + harness.awaitWorkerIdle() + assertEquals(1, harness.ackRequests.size) + } + + @Test + fun `an ack a process could not deliver is delivered by the next one`() { + val snapshotStore = InMemorySnapshotStore() + val ackStore = InMemoryActivationAckStore() + val crashed = harness(snapshotStore = snapshotStore, ackStore = ackStore) + crashed.serveAckStatus(HTTP_SERVICE_UNAVAILABLE) + crashed.identify("QON_anon_a", CANONICAL_A, RemoteConfigFetchForceReason.Build) + crashed.awaitCandidate(releaseNumber = 1) + crashed.activateBlocking() + crashed.awaitAcks(1) + // Walk the bounded retry ladder to its end, so the ack is abandoned in this "process". + repeat(REMOTE_CONFIG_ACK_MAX_ATTEMPTS - 1) { attempt -> + awaitScheduledRetry(crashed) + crashed.awaitAcks(attempt + 2) + } + assertEquals(REMOTE_CONFIG_ACK_MAX_ATTEMPTS, crashed.ackRequests.size) + assertEquals(1, crashed.ackSender.droppedAckCount) + + // A new process over the same durable state. + val restarted = harness(snapshotStore = snapshotStore, ackStore = ackStore) + restarted.identify("QON_anon_a", CANONICAL_A, RemoteConfigFetchForceReason.Build) + restarted.awaitAcks(1) + + assertTrue(restarted.ackRequests.single().body.startsWith("{\"release_number\":1,")) + assertEquals(0, restarted.ackSender.droppedAckCount) + // Nothing is owed any more, so a later activation of the same release stays silent. + restarted.activateBlocking() + restarted.awaitWorkerIdle() + assertEquals(1, restarted.ackRequests.size) + } + + @Test + fun `an SDK that never learned an identity acks nothing`() { + val harness = harness() + + harness.activateBlocking() + harness.awaitWorkerIdle() + + assertTrue(harness.ackRequests.isEmpty()) + assertTrue(harness.sessionRequests.isEmpty()) + } + + private fun awaitScheduledRetry(harness: RemoteConfigV2Harness) { + val deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(RC_AWAIT_SECONDS) + while (harness.ackScheduler.pendingCount() == 0 && System.currentTimeMillis() < deadline) { + Thread.sleep(POLL_INTERVAL_MILLIS) + } + harness.ackScheduler.runAll() + } + + private fun harness( + buildMode: RemoteConfigReadBuildMode = RemoteConfigReadBuildMode.Debug, + snapshotStore: InMemorySnapshotStore = InMemorySnapshotStore(), + ackStore: InMemoryActivationAckStore = InMemoryActivationAckStore(), + ) = RemoteConfigV2Harness( + buildMode = buildMode, + snapshotStore = snapshotStore, + ackStore = ackStore, + ).also { harnesses += it } + + private companion object { + const val POLL_INTERVAL_MILLIS = 10L + const val CANONICAL_A = "canonical-a" + const val RELEASE_2 = 2L + val RELEASE_NUMBER = Regex("\"release_number\":(\\d+)") + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigActivationAckTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigActivationAckTest.kt new file mode 100644 index 000000000..3b0851614 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigActivationAckTest.kt @@ -0,0 +1,453 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import com.squareup.moshi.Moshi +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import java.util.ArrayDeque +import java.util.Collections +import java.util.concurrent.TimeUnit + +/** + * The activation ack, end to end over a real [MockWebServer]: the shipped + * [RemoteConfigGatewayTransport] under the shipped [RemoteConfigActivationAckSender]. + * + * Only three things are doubles: the durable store is in memory, the retry scheduler is manual (so + * a bounded retry ladder can be walked without sleeping), and the jitter source is fixed. + */ +internal class RemoteConfigActivationAckTest { + private lateinit var server: MockWebServer + private lateinit var client: OkHttpClient + private lateinit var gateway: ScriptedGateway + private lateinit var store: InMemoryActivationAckStore + private lateinit var sessionStore: InMemorySessionStore + private lateinit var scheduler: ManualScheduler + private var identityScope: RemoteConfigSnapshotScope? = SCOPE_A + private val clock = MutableAckClock(ACTIVATED_AT_SECONDS * 1_000) + + @Before + fun setUp() { + server = MockWebServer() + gateway = ScriptedGateway() + server.dispatcher = gateway + server.start() + client = OkHttpClient() + store = InMemoryActivationAckStore() + scheduler = ManualScheduler() + identityScope = SCOPE_A + // An ack can only ever follow a snapshot read, so the realistic starting state is a session + // this installation already holds — which is also the state that licenses the single + // re-bootstrap on a 401. + sessionStore = InMemorySessionStore() + listOf(SCOPE_A, SCOPE_B).forEach { scope -> + sessionStore.save( + RemoteConfigSessionKey(scope, scope.canonicalUserId), + RemoteConfigGatewaySession( + token = SEEDED_SESSION_TOKEN, + projectId = RC_PROJECT_ID, + environment = "prod", + expiresAtMillis = clock.now + SESSION_LIFETIME_MILLIS, + ), + ) + } + } + + @After + fun tearDown() { + server.shutdown() + client.dispatcher().executorService().shutdownNow() + client.connectionPool().evictAll() + } + + @Test + fun `an ack matches the gateway contract exactly`() { + val sender = sender() + sender.bind(SCOPE_A) + + sender.recordActivation(SCOPE_A, RELEASE_7) + awaitAcks(1) + + val ack = gateway.acks.single() + assertEquals("POST", ack.method) + assertEquals(RC_ACK_PATH, ack.path) + assertEquals("Bearer $PROJECT_TOKEN", ack.authorization) + assertEquals("application/json; charset=utf-8", ack.contentType) + assertEquals(SEEDED_SESSION_TOKEN, ack.sessionHeader) + assertEquals("{\"release_number\":$RELEASE_7,\"activated_at\":$ACTIVATED_AT_SECONDS}", ack.body) + awaitRecord { it?.pending == null && it?.settledReleaseNumber == RELEASE_7 } + assertEquals(0, sender.droppedAckCount) + } + + @Test + fun `a delivered release is never acked twice`() { + val sender = sender() + sender.bind(SCOPE_A) + sender.recordActivation(SCOPE_A, RELEASE_7) + awaitAcks(1) + awaitRecord { it?.settledReleaseNumber == RELEASE_7 } + + // The same release re-reported (an implicit activation followed by an explicit activate()). + sender.recordActivation(SCOPE_A, RELEASE_7) + // ...and re-reported after a rebind, which is what a second cold start looks like. + sender.bind(SCOPE_A) + sender.recordActivation(SCOPE_A, RELEASE_7) + + assertEquals(1, gateway.acks.size) + } + + @Test + fun `a 401 re-bootstraps exactly once and retries the ack`() { + gateway.scriptAck(HTTP_UNAUTHORIZED) + val sender = sender() + sender.bind(SCOPE_A) + + sender.recordActivation(SCOPE_A, RELEASE_7) + awaitAcks(2) + + awaitRecord { it?.settledReleaseNumber == RELEASE_7 && it.pending == null } + // Exactly one mint: the single re-bootstrap the 401 licensed, and no other. + assertEquals(1, gateway.sessions.size) + assertEquals(listOf(SEEDED_SESSION_TOKEN, SESSION_TOKEN), gateway.acks.map { it.sessionHeader }) + assertEquals(0, sender.droppedAckCount) + // No retry was ever scheduled: the re-bootstrap is the transport's business, not the + // sender's retry budget. + assertEquals(emptyList(), scheduler.requestedDelays) + } + + @Test + fun `a 401 that survives the re-bootstrap is permanent`() { + gateway.scriptAck(HTTP_UNAUTHORIZED, HTTP_UNAUTHORIZED) + val sender = sender() + sender.bind(SCOPE_A) + + sender.recordActivation(SCOPE_A, RELEASE_7) + awaitAcks(2) + + awaitDropped(sender, 1) + // Permanent is an answer: the release is settled, not left owed. + awaitRecord { it?.pending == null && it?.settledReleaseNumber == RELEASE_7 } + scheduler.runAll() + assertEquals(2, gateway.acks.size) + } + + @Test + fun `a permanent refusal settles the release instead of forgetting it`() { + // A gateway that does not serve /ack at all answers every ack with 404. Forgetting such a + // release would re-queue and re-POST the same ack on every start and every binding. + gateway.scriptAck(HTTP_NOT_FOUND) + val sender = sender() + sender.bind(SCOPE_A) + + sender.recordActivation(SCOPE_A, RELEASE_7) + awaitAcks(1) + awaitDropped(sender, 1) + awaitRecord { it?.pending == null && it?.settledReleaseNumber == RELEASE_7 } + + scheduler.runAll() + sender.bind(SCOPE_A) + sender.recordActivation(SCOPE_A, RELEASE_7) + // A whole new process over the same durable state must stay silent as well. + sender().bind(SCOPE_A) + + assertEquals(1, gateway.acks.size) + } + + @Test + fun `a 503 is retried to the attempt bound and then dropped`() { + gateway.scriptAck(HTTP_SERVICE_UNAVAILABLE, HTTP_SERVICE_UNAVAILABLE, HTTP_SERVICE_UNAVAILABLE) + val sender = sender() + sender.bind(SCOPE_A) + + sender.recordActivation(SCOPE_A, RELEASE_7) + runRetryLadder(sender) + + // Three attempts, half-cap plus jitter, and nothing scheduled afterwards. + assertEquals(REMOTE_CONFIG_ACK_MAX_ATTEMPTS, gateway.acks.size) + assertEquals(listOf(750L, 1_500L), scheduler.requestedDelays) + assertEquals(0, scheduler.pendingCount()) + scheduler.runAll() + assertEquals(REMOTE_CONFIG_ACK_MAX_ATTEMPTS, gateway.acks.size) + // Dropped in this process, still owed: the record is what a later start picks up. + assertEquals(RELEASE_7, store.load(SCOPE_A)?.pending?.releaseNumber) + } + + @Test + fun `an exhausted retry ladder is not re-armed by a re-binding`() { + gateway.scriptAck(*IntArray(RETRY_SCRIPT_SIZE) { HTTP_SERVICE_UNAVAILABLE }) + val sender = sender() + sender.bind(SCOPE_A) + sender.recordActivation(SCOPE_A, RELEASE_7) + runRetryLadder(sender) + + // An identify that lands on the same identity, and a re-report of the same activation. + sender.bind(SCOPE_A) + sender.recordActivation(SCOPE_A, RELEASE_7) + sender.bind(SCOPE_B) + sender.bind(SCOPE_A) + + assertEquals(REMOTE_CONFIG_ACK_MAX_ATTEMPTS, gateway.acks.size) + // Only a NEWER release re-arms delivery. + sender.recordActivation(SCOPE_A, RELEASE_9) + awaitAcks(REMOTE_CONFIG_ACK_MAX_ATTEMPTS + 1) + assertEquals(RELEASE_9, gateway.acks.last().releaseNumber()) + } + + @Test + fun `a newer activation supersedes the queued one`() { + gateway.scriptAck(HTTP_SERVICE_UNAVAILABLE) + val sender = sender() + sender.bind(SCOPE_A) + sender.recordActivation(SCOPE_A, RELEASE_7) + awaitAcks(1) + + clock.now = LATER_ACTIVATED_AT_SECONDS * 1_000 + sender.recordActivation(SCOPE_A, RELEASE_9) + awaitAcks(2) + + assertEquals( + "{\"release_number\":$RELEASE_9,\"activated_at\":$LATER_ACTIVATED_AT_SECONDS}", + gateway.acks[1].body, + ) + awaitRecord { it?.settledReleaseNumber == RELEASE_9 && it.pending == null } + // The superseded retry never fires, so release 7 is never re-sent. + scheduler.runAll() + assertEquals(2, gateway.acks.size) + assertEquals(listOf(RELEASE_7, RELEASE_9), gateway.acks.map { it.releaseNumber() }) + } + + @Test + fun `a pending ack survives a process restart`() { + gateway.scriptAck(HTTP_SERVICE_UNAVAILABLE, HTTP_SERVICE_UNAVAILABLE, HTTP_SERVICE_UNAVAILABLE) + val crashed = sender() + crashed.bind(SCOPE_A) + crashed.recordActivation(SCOPE_A, RELEASE_7) + runRetryLadder(crashed) + + // A new process: new sender, new scheduler, same durable store. + scheduler = ManualScheduler() + clock.now = LATER_ACTIVATED_AT_SECONDS * 1_000 + val restarted = sender() + restarted.bind(SCOPE_A) + awaitAcks(4) + + // The ack still reports when the release was ACTIVATED, not when it was finally delivered. + assertEquals( + "{\"release_number\":$RELEASE_7,\"activated_at\":$ACTIVATED_AT_SECONDS}", + gateway.acks[3].body, + ) + awaitRecord { it != null && it.pending == null && it.settledReleaseNumber == RELEASE_7 } + assertEquals(0, restarted.droppedAckCount) + } + + @Test + fun `re-binding the same identity does not re-send an ack already under way`() { + gateway.scriptAck(HTTP_SERVICE_UNAVAILABLE) + val sender = sender() + sender.bind(SCOPE_A) + sender.recordActivation(SCOPE_A, RELEASE_7) + awaitAcks(1) + + // An identify that resolves to the identity already bound. + sender.bind(SCOPE_A) + + assertEquals(1, gateway.acks.size) + // ...and the retry the 503 armed is still the one that will run. + assertEquals(1, scheduler.pendingCount()) + scheduler.runAll() + awaitAcks(2) + awaitRecord { it?.settledReleaseNumber == RELEASE_7 } + assertEquals(2, gateway.acks.size) + } + + @Test + fun `an ack is never sent under another identity's session`() { + val sender = sender() + sender.bind(SCOPE_A) + // The transport now addresses another identity than the one the ack was queued for. + identityScope = SCOPE_B + + sender.recordActivation(SCOPE_A, RELEASE_7) + + assertEquals(emptyList(), gateway.acks) + assertEquals(0, sender.droppedAckCount) + // Not an attempt: the ack stays queued for the identity that owes it. + assertEquals(RELEASE_7, store.load(SCOPE_A)?.pending?.releaseNumber) + } + + @Test + fun `an activation of an unbound scope is ignored`() { + val sender = sender() + + sender.recordActivation(SCOPE_A, RELEASE_7) + sender.bind(SCOPE_B) + sender.recordActivation(SCOPE_A, RELEASE_7) + + assertEquals(emptyList(), gateway.acks) + assertNull(store.load(SCOPE_A)) + } + + @Test + fun `binding another identity fences an ack that is already on the wire`() { + gateway.scriptAck(HTTP_SERVICE_UNAVAILABLE) + val sender = sender() + sender.bind(SCOPE_A) + sender.recordActivation(SCOPE_A, RELEASE_7) + awaitAcks(1) + + identityScope = SCOPE_B + sender.bind(SCOPE_B) + scheduler.runAll() + + // The retry the 503 scheduled belongs to the previous identity and must not fire. + assertEquals(1, gateway.acks.size) + assertEquals(RELEASE_7, store.load(SCOPE_A)?.pending?.releaseNumber) + } + + @Test + fun `a release number that could never address anything is refused`() { + val sender = sender() + sender.bind(SCOPE_A) + + sender.recordActivation(SCOPE_A, 0) + sender.recordActivation(SCOPE_A, -1) + + assertEquals(emptyList(), gateway.acks) + assertNull(store.load(SCOPE_A)) + } + + private fun sender() = RemoteConfigActivationAckSender( + transport = transport(), + store = store, + clock = clock, + random = { 0.5 }, + scheduler = scheduler, + ) + + private fun transport() = RemoteConfigGatewayTransport( + callFactory = client, + baseUrlProvider = { server.url("/").toString() }, + identityProvider = { + identityScope?.let { scope -> + RemoteConfigTransportIdentity(scope, PROJECT_TOKEN, scope.canonicalUserId) + } + }, + clientContextProvider = { null }, + sessionStore = sessionStore, + projectIds = RemoteConfigProjectIdRegistry(InMemoryProjectIdStore()), + clock = clock, + moshi = Moshi.Builder().build(), + logger = SilentLogger(), + ) + + /** Walks the bounded retry ladder to its end, without racing the timer it arms. */ + private fun runRetryLadder(sender: RemoteConfigActivationAckSender) { + repeat(REMOTE_CONFIG_ACK_MAX_ATTEMPTS - 1) { attempt -> + awaitAcks(attempt + 1) + await("no retry was scheduled after attempt ${attempt + 1}") { scheduler.pendingCount() > 0 } + scheduler.runAll() + } + awaitAcks(REMOTE_CONFIG_ACK_MAX_ATTEMPTS) + awaitDropped(sender, 1) + } + + private fun awaitAcks(count: Int) = await("expected $count acks, saw ${gateway.acks.size}") { + gateway.acks.size >= count + } + + private fun awaitDropped(sender: RemoteConfigActivationAckSender, count: Long) = + await("expected $count dropped acks, saw ${sender.droppedAckCount}") { + sender.droppedAckCount >= count + } + + private fun awaitRecord(predicate: (RemoteConfigActivationAckRecord?) -> Boolean) = + await("durable ack record never reached the expected shape: ${store.load(SCOPE_A)}") { + predicate(store.load(SCOPE_A)) + } + + private fun await(message: String, condition: () -> Boolean) { + val deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(RC_AWAIT_SECONDS) + while (System.currentTimeMillis() < deadline) { + if (condition()) return + Thread.sleep(POLL_INTERVAL_MILLIS) + } + throw AssertionError(message) + } + + private fun RecordedAck.releaseNumber(): Long = + RELEASE_NUMBER_PATTERN.find(body)?.groupValues?.get(1)?.toLong() ?: 0 + + private class MutableAckClock(@Volatile var now: Long) : RemoteConfigFetchClock { + override fun nowMillis(): Long = now + } + + private data class RecordedAck( + val method: String, + val path: String?, + val body: String, + val sessionHeader: String?, + val authorization: String?, + val contentType: String?, + ) + + /** Answers by path, so an ack and a bootstrap are never order-coupled. */ + private class ScriptedGateway : Dispatcher() { + val acks: MutableList = Collections.synchronizedList(mutableListOf()) + val sessions: MutableList = Collections.synchronizedList(mutableListOf()) + private val ackStatuses = ArrayDeque() + + /** Answers the next acks with [statuses], then `204` forever. */ + fun scriptAck(vararg statuses: Int) { + synchronized(ackStatuses) { statuses.forEach(ackStatuses::addLast) } + } + + override fun dispatch(request: RecordedRequest): MockResponse = when (request.path) { + RC_SESSION_PATH -> { + sessions += request.body.readUtf8() + MockResponse().setResponseCode(HTTP_OK).setBody(sessionBody(sessions.size)) + } + RC_ACK_PATH -> { + acks += RecordedAck( + method = request.method.orEmpty(), + path = request.path, + body = request.body.readUtf8(), + sessionHeader = request.getHeader(REMOTE_CONFIG_SESSION_HEADER), + authorization = request.getHeader("Authorization"), + contentType = request.getHeader("Content-Type"), + ) + MockResponse().setResponseCode( + synchronized(ackStatuses) { ackStatuses.pollFirst() } ?: HTTP_NO_CONTENT, + ) + } + else -> MockResponse().setResponseCode(HTTP_NOT_FOUND) + } + + private fun sessionBody(ordinal: Int): String { + val token = if (ordinal == 1) SESSION_TOKEN else "$SESSION_TOKEN-$ordinal" + return "{\"session_token\":\"$token\",\"project_id\":$RC_PROJECT_ID," + + "\"environment\":\"prod\",\"expires_at\":\"2030-01-01T00:00:00Z\"}" + } + } + + private companion object { + const val POLL_INTERVAL_MILLIS = 10L + const val PROJECT_TOKEN = "project-token" + const val SESSION_TOKEN = "qrcs1.session" + const val SEEDED_SESSION_TOKEN = "qrcs1.seeded-session" + const val SESSION_LIFETIME_MILLIS = 3_600_000L + const val RELEASE_7 = 7L + const val RELEASE_9 = 9L + const val ACTIVATED_AT_SECONDS = 1_700_000_000L + const val LATER_ACTIVATED_AT_SECONDS = 1_700_000_900L + const val RETRY_SCRIPT_SIZE = 8 + val RELEASE_NUMBER_PATTERN = Regex("\"release_number\":(\\d+)") + val SCOPE_A = RemoteConfigSnapshotScope(RC_PROJECT_KEY, RC_ENVIRONMENT, "QON_anon_a") + val SCOPE_B = RemoteConfigSnapshotScope(RC_PROJECT_KEY, RC_ENVIRONMENT, "QON_anon_b") + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigDecodeFailureTelemetryTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigDecodeFailureTelemetryTest.kt new file mode 100644 index 000000000..a4c9b643c --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigDecodeFailureTelemetryTest.kt @@ -0,0 +1,152 @@ +@file:OptIn(ExperimentalQonversionApi::class) + +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Collections + +private const val RELEASE_1 = 1L +private const val RELEASE_2 = 2L +private const val CACHED_VALUE = "cached" +private const val SERVED_VALUE = "served" + +/** + * The `decode_failure` production point, at the snapshot read site. + * + * This is the one telemetry kind with no other seam: a failed typed decode is absorbed by the + * resolution ladder by design, so nothing downstream of the read can tell a mis-typed key from an + * absent one. The tests therefore assert two things at once, and the first one is the important + * one: **the value the caller receives is exactly what it would be without telemetry.** + */ +internal class RemoteConfigDecodeFailureTelemetryTest { + private val reported: MutableList> = Collections.synchronizedList(mutableListOf()) + private val observer = RemoteConfigDecodeFailureObserver { key, release -> reported += key to release } + private var harness: RemoteConfigV2Harness? = null + + @After + fun tearDown() { + harness?.shutdown() + } + + @Test + fun `a failed decode of the served release falls through to the cached one and is reported`() { + val snapshot = snapshot( + primary = release(RELEASE_2, "count" to "\"not-a-number\""), + previous = release(RELEASE_1, "count" to "7"), + ) + + val resolved = snapshot.value("count") { bytes -> bytes.toString(Charsets.UTF_8).toLongOrNull() } + + // The ladder is unchanged: the cached release still serves the value. + assertEquals(7L, resolved?.value) + assertEquals(RemoteConfigSnapshotValueSource.Cache, resolved?.source) + assertEquals(listOf("count" to RELEASE_2), reported) + } + + @Test + fun `a failed decode with nothing below it still returns null and is reported once`() { + val snapshot = snapshot(primary = release(RELEASE_2, "count" to "\"not-a-number\"")) + + val resolved = snapshot.value("count") { bytes -> bytes.toString(Charsets.UTF_8).toLongOrNull() } + + assertNull(resolved) + assertEquals(listOf("count" to RELEASE_2), reported) + } + + @Test + fun `a decoder that throws is reported exactly like one that returns null`() { + val snapshot = snapshot(primary = release(RELEASE_2, "count" to "1")) + + val resolved = snapshot.value("count") { error("the app's decoder blew up") } + + assertNull(resolved) + assertEquals(listOf("count" to RELEASE_2), reported) + } + + @Test + fun `a successful decode reports nothing`() { + val snapshot = snapshot(primary = release(RELEASE_2, "count" to "7")) + + val resolved = snapshot.value("count") { bytes -> bytes.toString(Charsets.UTF_8).toLongOrNull() } + + assertEquals(7L, resolved?.value) + assertTrue(reported.isEmpty()) + } + + @Test + fun `a key the served release does not carry is not a decode failure`() { + val snapshot = snapshot( + primary = release(RELEASE_2, "other" to "7"), + bundled = release(RELEASE_1, "count" to "\"$CACHED_VALUE\""), + ) + + // Falling back because the key is absent is the ladder working, not a client defect. + val resolved = snapshot.value("count") { bytes -> bytes.toString(Charsets.UTF_8).trim('"') } + + assertEquals(CACHED_VALUE, resolved?.value) + assertTrue(reported.isEmpty()) + } + + @Test + fun `an observer that throws cannot break a read`() { + val snapshot = RemoteConfigSnapshot( + primaryRelease = release(RELEASE_2, "count" to "\"not-a-number\""), + previousRelease = release(RELEASE_1, "count" to "7"), + bundledRelease = null, + decodeFailureObserver = { _, _ -> error("telemetry blew up") }, + ) + + val resolved = snapshot.value("count") { bytes -> bytes.toString(Charsets.UTF_8).toLongOrNull() } + + assertEquals(7L, resolved?.value) + } + + @Test + fun `repeated failures of the same key coalesce into one telemetry entry`() { + // No bundled defaults: the fallback rung would otherwise decode the key and hide the + // failure this test is about. + val started = RemoteConfigV2Harness(bundled = null).also { harness = it } + started.serve("release-1", RELEASE_1, listOf(RcWireValue("count", "\"$SERVED_VALUE\""))) + started.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + started.fetchBlocking() + started.activateBlocking() + started.awaitWorkerIdle() + + // The app reads the same key with the wrong type, over and over. + val snapshot = started.core.currentSnapshot() + repeat(5) { + assertNull(snapshot.value("count") { bytes -> bytes.toString(Charsets.UTF_8).toLongOrNull() }) + } + + started.awaitWorkerIdle() + // Bounded: five failed reads are one buffered entry, not five. + assertEquals(1, started.telemetrySender.pendingEntryCount) + assertEquals(0, started.telemetrySender.droppedEventCount) + } + + private fun snapshot( + primary: RemoteConfigSnapshotRelease? = null, + previous: RemoteConfigSnapshotRelease? = null, + bundled: RemoteConfigSnapshotRelease? = null, + ) = RemoteConfigSnapshot(primary, previous, bundled, observer) + + private fun release(releaseNumber: Long, vararg values: Pair) = RemoteConfigSnapshotRelease( + releaseUid = "release-$releaseNumber", + releaseNumber = releaseNumber, + manifestContentHash = "1".repeat(RC_FINGERPRINT_LENGTH), + entries = values.map { (key, raw) -> + RemoteConfigSnapshotEntry.value( + key = key, + rawValue = raw.encodeToByteArray(), + variationUid = "var-$key-$releaseNumber", + applyPolicy = RemoteConfigSnapshotApplyPolicy.OnNextActivate, + metadata = null, + ) + }, + ) +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinatorTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinatorTest.kt new file mode 100644 index 000000000..507db6ed7 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinatorTest.kt @@ -0,0 +1,714 @@ +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 org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.security.MessageDigest +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +internal class RemoteConfigFetchCoordinatorTest { + private val scope = RemoteConfigSnapshotScope("project", "production", "canonical-user") + + @Test + fun `concurrent fetches coalesce into one transport request`() { + val transport = RecordingTransport() + val coordinator = coordinator(transport) + coordinator.transitionTo(scope) + val results = mutableListOf() + + coordinator.fetch(callback = results::add) + coordinator.fetch(forceReason = RemoteConfigFetchForceReason.Identify, callback = results::add) + + assertEquals(1, transport.requests.size) + transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 400)) + assertEquals(2, results.size) + } + + @Test + fun `minimum interval is persisted while an explicit lifecycle fetch bypasses it`() { + val transport = RecordingTransport() + val clock = MutableClock(1_000) + val policyStore = InMemoryFetchPolicyStore() + val coordinator = coordinator( + transport = transport, + clock = clock, + policyStore = policyStore, + policy = RemoteConfigFetchPolicy(minimumFetchIntervalMillis = 60_000), + ) + coordinator.transitionTo(scope) + coordinator.fetch(callback = {}) + transport.complete(success("first", 1)) + + val gated = mutableListOf() + coordinator.fetch(callback = gated::add) + assertEquals(1, transport.requests.size) + assertTrue(gated.single() is RemoteConfigFetchResult.MinimumInterval) + + coordinator.fetch(forceReason = RemoteConfigFetchForceReason.Build, callback = {}) + assertEquals(2, transport.requests.size) + } + + @Test + fun `retry after persists across restart and lifecycle force does not bypass backoff`() { + val transport = RecordingTransport() + val clock = MutableClock(1_000) + val policyStore = InMemoryFetchPolicyStore() + val policy = RemoteConfigFetchPolicy( + minimumFetchIntervalMillis = 0, + initialBackoffMillis = 1_000, + maximumBackoffMillis = 10_000, + ) + val first = coordinator(transport, clock, policyStore, policy) + first.transitionTo(scope) + first.fetch(callback = {}) + transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 429, retryAfterMillis = 4_000)) + + val forced = mutableListOf() + first.fetch(forceReason = RemoteConfigFetchForceReason.Identify, callback = forced::add) + assertEquals(1, transport.requests.size) + assertEquals(5_000L, (forced.single() as RemoteConfigFetchResult.Backoff).nextAllowedAtMillis) + + val restartedTransport = RecordingTransport() + val restarted = coordinator(restartedTransport, clock, policyStore, policy) + restarted.transitionTo(scope) + val beforeDeadline = mutableListOf() + restarted.fetch(callback = beforeDeadline::add) + assertTrue(beforeDeadline.single() is RemoteConfigFetchResult.Backoff) + assertEquals(0, restartedTransport.requests.size) + + clock.now = 5_000 + restarted.fetch(callback = {}) + assertEquals(1, restartedTransport.requests.size) + } + + @Test + fun `identify cannot evade project environment backoff by changing canonical identity`() { + val transport = RecordingTransport() + val clock = MutableClock(1_000) + val coordinator = coordinator( + transport = transport, + clock = clock, + policy = RemoteConfigFetchPolicy( + minimumFetchIntervalMillis = 0, + initialBackoffMillis = 1_000, + maximumBackoffMillis = 10_000, + ), + ) + coordinator.transitionTo(scope) + coordinator.fetch(callback = {}) + transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 429, retryAfterMillis = 4_000)) + + coordinator.transitionTo(RemoteConfigSnapshotScope("project", "production", "identified-user")) + val result = mutableListOf() + coordinator.fetch(forceReason = RemoteConfigFetchForceReason.Identify, callback = result::add) + + assertEquals(1, transport.requests.size) + assertEquals(5_000L, (result.single() as RemoteConfigFetchResult.Backoff).nextAllowedAtMillis) + } + + @Test + fun `retryable failures use capped exponential full jitter and success resets it`() { + val transport = RecordingTransport() + val clock = MutableClock(1_000) + val random = MutableRandom(0.5) + val policyStore = InMemoryFetchPolicyStore() + val coordinator = coordinator( + transport = transport, + clock = clock, + random = random, + policyStore = policyStore, + policy = RemoteConfigFetchPolicy( + minimumFetchIntervalMillis = 0, + initialBackoffMillis = 1_000, + maximumBackoffMillis = 1_500, + ), + ) + coordinator.transitionTo(scope) + coordinator.fetch(callback = {}) + transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 500)) + assertEquals( + RemoteConfigFetchPolicyState( + consecutiveRetryableFailures = 1, + nextAllowedFetchAtMillis = 1_500, + ), + policyStore.load(RemoteConfigFetchPolicyScope.from(scope)), + ) + + clock.now = 1_500 + random.value = Math.nextDown(1.0) + coordinator.fetch(callback = {}) + transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 503)) + assertEquals( + 2_999L, + policyStore.load(RemoteConfigFetchPolicyScope.from(scope))?.nextAllowedFetchAtMillis, + ) + + clock.now = 2_999 + coordinator.fetch(callback = {}) + transport.complete(success("recovered", 2)) + assertEquals( + RemoteConfigFetchPolicyState(lastSuccessfulFetchAtMillis = 2_999), + policyStore.load(RemoteConfigFetchPolicyScope.from(scope)), + ) + } + + @Test + fun `timeout serves Active without cancelling request and late response persists Candidate`() { + val transport = RecordingTransport() + val scheduler = ManualScheduler() + val snapshotStore = InMemorySnapshotStore() + val bundle = RemoteConfigScopedBundledRelease( + projectKey = "project", + environment = "production", + release = release("bundle", 1, "0"), + ) + val core = RemoteConfigSnapshotCore(snapshotStore, bundle) + val coordinator = coordinator( + transport = transport, + core = core, + scheduler = scheduler, + policy = RemoteConfigFetchPolicy(minimumFetchIntervalMillis = 0, timeoutMillis = 100), + ) + coordinator.transitionTo(scope) + core.acceptCandidate(scope, release("active", 1, "1")) + core.activate() + val results = mutableListOf() + + coordinator.fetch(callback = results::add) + scheduler.runNext() + + val timedOut = results.single() as RemoteConfigFetchResult.TimedOut + assertEquals("1", timedOut.snapshot.rawValue("a")?.value?.decodeToString()) + transport.complete(success("candidate", 2)) + assertEquals(1, results.size) + assertEquals("1", core.currentSnapshot().rawValue("a")?.value?.decodeToString()) + assertEquals("candidate", core.lastFetchedSnapshot()?.releaseUid) + } + + @Test + fun `first fetch after every waiter timed out fences zombie operation without cancelling HTTP`() { + val transport = RecordingTransport() + val scheduler = ManualScheduler() + val core = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundledRelease = null) + val coordinator = coordinator( + transport = transport, + core = core, + scheduler = scheduler, + policy = RemoteConfigFetchPolicy(minimumFetchIntervalMillis = 0, timeoutMillis = 100), + ) + coordinator.transitionTo(scope) + val first = mutableListOf() + val second = mutableListOf() + coordinator.fetch(callback = first::add) + scheduler.runNext() + + coordinator.fetch(forceReason = RemoteConfigFetchForceReason.Build, callback = second::add) + + assertEquals(2, transport.requests.size) + transport.complete(success("late-zombie", 1)) + assertEquals(null, core.lastFetchedSnapshot()) + transport.complete(success("current", 2)) + assertEquals("current", core.lastFetchedSnapshot()?.releaseUid) + assertTrue(second.single() is RemoteConfigFetchResult.Fetched) + } + + @Test + fun `timeout falls through to matching bundled defaults when no Active exists`() { + val transport = RecordingTransport() + val scheduler = ManualScheduler() + val bundle = RemoteConfigScopedBundledRelease( + projectKey = "project", + environment = "production", + release = release("bundle", 1, "0"), + ) + val core = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundle) + val coordinator = coordinator( + transport = transport, + core = core, + scheduler = scheduler, + policy = RemoteConfigFetchPolicy(minimumFetchIntervalMillis = 0, timeoutMillis = 100), + ) + coordinator.transitionTo(scope) + val results = mutableListOf() + + coordinator.fetch(callback = results::add) + scheduler.runNext() + + val timedOut = results.single() as RemoteConfigFetchResult.TimedOut + assertEquals("0", timedOut.snapshot.rawValue("a")?.value?.decodeToString()) + } + + @Test + fun `conditional fetch sends strong ETag only for an exact local canonical body`() { + val transport = RecordingTransport() + val core = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundledRelease = null) + val coordinator = coordinator(transport = transport, core = core) + coordinator.transitionTo(scope) + val first = success("first", 1) + coordinator.fetch(callback = {}) + transport.complete(first) + + val results = mutableListOf() + coordinator.fetch(callback = results::add) + + assertEquals(first.etag, transport.requests.last().ifNoneMatch) + transport.complete(RemoteConfigFetchResponse.NotModified(etag = first.etag)) + assertTrue(results.single() is RemoteConfigFetchResult.NotModified) + assertEquals("first", core.lastFetchedSnapshot()?.releaseUid) + } + + @Test + fun `conditional validator never falls through a noncanonical Candidate to canonical Active`() { + val transport = RecordingTransport() + val core = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundledRelease = null) + val coordinator = coordinator(transport = transport, core = core) + coordinator.transitionTo(scope) + coordinator.fetch(callback = {}) + transport.complete(success("active", 1)) + core.activate() + core.acceptCandidate(scope, release("local-candidate", 2, "2")) + + coordinator.fetch(callback = {}) + + assertEquals(null, transport.requests.last().ifNoneMatch) + } + + @Test + fun `304 validator invalidated by an intervening head retries once without ETag`() { + val transport = RecordingTransport() + val core = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundledRelease = null) + val coordinator = coordinator(transport = transport, core = core) + coordinator.transitionTo(scope) + val canonical = success("canonical", 1) + coordinator.fetch(callback = {}) + transport.complete(canonical) + coordinator.fetch(callback = {}) + assertEquals(canonical.etag, transport.requests.last().ifNoneMatch) + + core.acceptCandidate(scope, release("intervening", 2, "2")) + transport.complete(RemoteConfigFetchResponse.NotModified(etag = canonical.etag)) + + assertEquals(3, transport.requests.size) + assertEquals(null, transport.requests.last().ifNoneMatch) + transport.complete(success("after-intervening", 3)) + assertEquals("after-intervening", core.lastFetchedSnapshot()?.releaseUid) + } + + @Test + fun `304 without matching canonical body retries exactly once without ETag`() { + val transport = RecordingTransport() + val core = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundledRelease = null) + val coordinator = coordinator(transport = transport, core = core) + coordinator.transitionTo(scope) + val results = mutableListOf() + + coordinator.fetch(callback = results::add) + assertEquals(null, transport.requests.single().ifNoneMatch) + transport.complete(RemoteConfigFetchResponse.NotModified(etag = "\"${"f".repeat(64)}\"")) + + assertEquals(2, transport.requests.size) + assertEquals(null, transport.requests.last().ifNoneMatch) + assertTrue(results.isEmpty()) + transport.complete(RemoteConfigFetchResponse.NotModified()) + assertEquals(2, transport.requests.size) + assertTrue(results.single() is RemoteConfigFetchResult.InvalidNotModified) + } + + @Test + fun `identity transition completes old waiters and fences the late response`() { + val transport = RecordingTransport() + val core = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundledRelease = null) + val coordinator = coordinator(transport = transport, core = core) + coordinator.transitionTo(scope) + val oldResults = mutableListOf() + coordinator.fetch(callback = oldResults::add) + + val nextScope = RemoteConfigSnapshotScope("project", "production", "canonical-user-next") + coordinator.transitionTo(nextScope) + assertEquals(listOf(RemoteConfigFetchResult.Superseded), oldResults) + transport.complete(success("late-private", 1)) + assertEquals(null, core.lastFetchedSnapshot()) + + val nextResults = mutableListOf() + coordinator.fetch(forceReason = RemoteConfigFetchForceReason.Identify, callback = nextResults::add) + // The session the response arrived on was minted for another project than the envelope + // names, so the admission is refused rather than stored under the new identity. + transport.complete(success("wrong-project", 1, projectId = 43)) + val transition = (nextResults.single() as RemoteConfigFetchResult.Fetched).transition + assertEquals(RemoteConfigSnapshotTransitionStatus.Rejected, transition.status) + assertEquals(null, core.lastFetchedSnapshot()) + } + + @Test + fun `same visible scope can be explicitly generation fenced on identify`() { + val transport = RecordingTransport() + val core = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundledRelease = null) + val coordinator = coordinator(transport = transport, core = core) + coordinator.transitionTo(scope) + val results = mutableListOf() + coordinator.fetch(callback = results::add) + + coordinator.transitionTo(scope) + transport.complete(success("stale", 1)) + + assertEquals(listOf(RemoteConfigFetchResult.Superseded), results) + assertEquals(null, core.lastFetchedSnapshot()) + } + + @Test + fun `identity transition waits for admitted response and its callback delivery boundary`() { + val parserStarted = CountDownLatch(1) + val releaseParser = CountDownLatch(1) + val parser = RemoteConfigSnapshotEnvelopeDecoder { body, etag, expectation -> + parserStarted.countDown() + assertTrue(releaseParser.await(30, TimeUnit.SECONDS)) + RemoteConfigSnapshotEnvelopeParser().parse(body, etag, expectation) + } + val transport = RecordingTransport() + val core = RemoteConfigSnapshotCore( + store = InMemorySnapshotStore(), + bundledRelease = null, + envelopeParser = parser, + ) + val coordinator = coordinator(transport = transport, core = core) + coordinator.transitionTo(scope) + val events = Collections.synchronizedList(mutableListOf()) + val results = Collections.synchronizedList(mutableListOf()) + coordinator.fetch { result -> + events += "callback" + results += result + } + + val responseThread = Thread { transport.complete(success("admitted", 1)) } + responseThread.start() + assertTrue(parserStarted.await(30, TimeUnit.SECONDS)) + val transitionFinished = CountDownLatch(1) + val transitionEntered = CountDownLatch(1) + val transitionThread = Thread { + transitionEntered.countDown() + coordinator.transitionTo(null) + events += "transition" + transitionFinished.countDown() + } + transitionThread.start() + + // Wait for the thread to actually be running before timing it: without this the + // "did not finish in 100 ms" check also passes when the thread was never scheduled, + // which turns the ordering assertion below into a race on a loaded machine. + assertTrue(transitionEntered.await(30, TimeUnit.SECONDS)) + // The guarantee under test: a transition cannot run while a response is being admitted. + assertFalse(transitionFinished.await(100, TimeUnit.MILLISECONDS)) + releaseParser.countDown() + responseThread.join(30_000) + transitionThread.join(30_000) + + // Both halves finish, and the waiter is answered exactly once. + assertEquals(setOf("callback", "transition"), events.toSet()) + assertEquals(2, events.size) + assertEquals(1, results.size) + // Their ORDER is deliberately not asserted. The coordinator drains deliveries outside + // `operationLock`, so a transition that takes the lock in the window between the admission + // releasing it and the drain reaching the waiter legitimately returns first — and then the + // waiter is told `Superseded` rather than `Fetched`, because its identity is already gone. + // Both outcomes are correct; pinning the interleaving only made this test flaky under load. + assertTrue( + "unexpected result: ${results.single()}", + results.single() is RemoteConfigFetchResult.Fetched || + results.single() == RemoteConfigFetchResult.Superseded, + ) + } + + @Test + fun `one throwing coalesced callback cannot starve the remaining waiters`() { + val transport = RecordingTransport() + val coordinator = coordinator(transport) + coordinator.transitionTo(scope) + val delivered = mutableListOf() + coordinator.fetch { throw AssertionError("consumer failure") } + coordinator.fetch(callback = delivered::add) + + transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 400)) + + assertTrue(delivered.single() is RemoteConfigFetchResult.Failed) + } + + @Test + fun `reentrant identity transition converts every remaining claimed callback to Superseded`() { + val transport = RecordingTransport() + val coordinator = coordinator(transport) + coordinator.transitionTo(scope) + val first = mutableListOf() + val second = mutableListOf() + coordinator.fetch { result -> + first += result + coordinator.transitionTo(null) + } + coordinator.fetch(callback = second::add) + + transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 400)) + + assertTrue(first.single() is RemoteConfigFetchResult.Failed) + assertEquals(listOf(RemoteConfigFetchResult.Superseded), second) + } + + @Test + fun `callback delivery holds no coordinator monitor needed by a concurrent transition`() { + val transport = RecordingTransport() + val coordinator = coordinator(transport) + coordinator.transitionTo(scope) + val transitionCompletedInsideCallback = AtomicBoolean(false) + coordinator.fetch { + val completed = CountDownLatch(1) + Thread { + coordinator.transitionTo(null) + completed.countDown() + }.start() + transitionCompletedInsideCallback.set(completed.await(2, TimeUnit.SECONDS)) + } + + transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 400)) + + assertTrue(transitionCompletedInsideCallback.get()) + } + + @Test + fun `failed durable backoff is observable conservative in process and explicit after restart`() { + val transport = RecordingTransport() + val clock = MutableClock(1_000) + val policyStore = InMemoryFetchPolicyStore(saveSucceeds = false) + val policy = RemoteConfigFetchPolicy( + minimumFetchIntervalMillis = 0, + initialBackoffMillis = 1_000, + maximumBackoffMillis = 10_000, + ) + val coordinator = coordinator(transport, clock, policyStore, policy) + coordinator.transitionTo(scope) + val failed = mutableListOf() + coordinator.fetch(callback = failed::add) + transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 500)) + + assertTrue(failed.single() is RemoteConfigFetchResult.PolicyPersistenceFailed) + val guarded = mutableListOf() + coordinator.fetch(forceReason = RemoteConfigFetchForceReason.Build, callback = guarded::add) + assertTrue(guarded.single() is RemoteConfigFetchResult.Backoff) + + val restartedTransport = RecordingTransport() + val restarted = coordinator(restartedTransport, clock, policyStore, policy) + restarted.transitionTo(scope) + restarted.fetch(callback = {}) + assertEquals(1, restartedTransport.requests.size) + } + + @Test + fun `transport and timeout scheduler failures still complete or continue the operation`() { + val transportFailure = coordinator(RemoteConfigFetchTransport { _, _ -> error("transport") }) + transportFailure.transitionTo(scope) + val failed = mutableListOf() + transportFailure.fetch(callback = failed::add) + assertTrue(failed.single() is RemoteConfigFetchResult.Failed) + + val transport = RecordingTransport() + val schedulerFailure = coordinator( + transport = transport, + scheduler = RemoteConfigFetchScheduler { _, _ -> error("scheduler") }, + policy = RemoteConfigFetchPolicy(minimumFetchIntervalMillis = 0, timeoutMillis = 100), + ) + schedulerFailure.transitionTo(scope) + val recovered = mutableListOf() + schedulerFailure.fetch(callback = recovered::add) + transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 400)) + assertTrue(recovered.single() is RemoteConfigFetchResult.Failed) + } + + @Test + fun `unbounded restored deadline is clamped to max and sanitized durably`() { + val transport = RecordingTransport() + val policyStore = InMemoryFetchPolicyStore().apply { + save( + RemoteConfigFetchPolicyScope.from(scope), + RemoteConfigFetchPolicyState( + consecutiveRetryableFailures = 63, + nextAllowedFetchAtMillis = Long.MAX_VALUE, + ), + ) + } + val coordinator = coordinator( + transport = transport, + policyStore = policyStore, + policy = RemoteConfigFetchPolicy( + minimumFetchIntervalMillis = 0, + initialBackoffMillis = 1_000, + maximumBackoffMillis = 10_000, + ), + ) + coordinator.transitionTo(scope) + val result = mutableListOf() + coordinator.fetch(callback = result::add) + + assertEquals(0, transport.requests.size) + assertEquals(11_000L, (result.single() as RemoteConfigFetchResult.Backoff).nextAllowedAtMillis) + assertEquals( + 11_000L, + policyStore.load(RemoteConfigFetchPolicyScope.from(scope))?.nextAllowedFetchAtMillis, + ) + } + + @Test + fun `invalid random sources always produce conservative nonzero jitter`() { + val randoms = listOf( + RemoteConfigFetchRandom { throw IllegalStateException("rng") }, + RemoteConfigFetchRandom { Double.NaN }, + RemoteConfigFetchRandom { -1.0 }, + RemoteConfigFetchRandom { 1.0 }, + ) + randoms.forEach { invalidRandom -> + val transport = RecordingTransport() + val policyStore = InMemoryFetchPolicyStore() + val coordinator = coordinator( + transport = transport, + random = invalidRandom, + policyStore = policyStore, + policy = RemoteConfigFetchPolicy( + minimumFetchIntervalMillis = 0, + initialBackoffMillis = 1_000, + maximumBackoffMillis = 10_000, + ), + ) + coordinator.transitionTo(scope) + coordinator.fetch(callback = {}) + transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 500)) + assertTrue( + requireNotNull( + policyStore.load(RemoteConfigFetchPolicyScope.from(scope)), + ).nextAllowedFetchAtMillis > 1_000, + ) + } + } + + private fun coordinator( + transport: RemoteConfigFetchTransport, + clock: MutableClock = MutableClock(1_000), + policyStore: InMemoryFetchPolicyStore = InMemoryFetchPolicyStore(), + policy: RemoteConfigFetchPolicy = RemoteConfigFetchPolicy(minimumFetchIntervalMillis = 0), + random: RemoteConfigFetchRandom = MutableRandom(0.5), + core: RemoteConfigSnapshotCore = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundledRelease = null), + scheduler: RemoteConfigFetchScheduler = RemoteConfigFetchScheduler { _, _ -> + RemoteConfigFetchScheduledTask {} + }, + ): RemoteConfigFetchCoordinator { + return RemoteConfigFetchCoordinator( + core = core, + transport = transport, + policyStore = policyStore, + clock = clock, + random = random, + scheduler = scheduler, + policy = policy, + ) + } + + private fun success(uid: String, number: Long, projectId: Long = 42): RemoteConfigFetchResponse.Success { + val body = wireBody(uid, number).encodeToByteArray() + return RemoteConfigFetchResponse.Success(body, strongETag(body), projectId) + } + + private fun wireBody(uid: String, number: Long) = + "{\"schema_version\":1,\"project_id\":42,\"environment_uid\":\"production\"," + + "\"release_uid\":\"$uid\",\"release_number\":$number," + + "\"manifest_content_hash\":\"${number.toString(16).padStart(64, '0')}\"," + + "\"complete_key_set\":true,\"context_fingerprint\":\"${"a".repeat(64)}\"," + + "\"values\":{\"a\":{\"raw\":$number,\"variation_uid\":\"variation-$uid\"," + + "\"apply_policy\":\"on_next_activate\",\"metadata\":null}}}" + + private fun strongETag(body: ByteArray): String = MessageDigest.getInstance("SHA-256") + .digest(body) + .joinToString(prefix = "\"", postfix = "\"", separator = "") { byte -> "%02x".format(byte) } + + private fun release(uid: String, number: Long, value: String) = RemoteConfigSnapshotRelease( + releaseUid = uid, + releaseNumber = number, + manifestContentHash = number.toString(16).padStart(64, '0'), + entries = listOf( + RemoteConfigSnapshotEntry.value( + key = "a", + rawValue = value.encodeToByteArray(), + variationUid = "variation-$uid", + applyPolicy = RemoteConfigSnapshotApplyPolicy.OnNextActivate, + metadata = null, + ), + ), + ) + + private class MutableClock(var now: Long) : RemoteConfigFetchClock { + override fun nowMillis(): Long = now + } + + private class MutableRandom(var value: Double) : RemoteConfigFetchRandom { + override fun nextDouble(): Double = value + } + + private class ManualScheduler : RemoteConfigFetchScheduler { + private val tasks = mutableListOf() + + override fun schedule(delayMillis: Long, action: () -> Unit): RemoteConfigFetchScheduledTask { + val task = Task(action) + tasks += task + return RemoteConfigFetchScheduledTask { task.cancelled = true } + } + + fun runNext() { + val task = tasks.removeAt(0) + if (!task.cancelled) task.action() + } + + private data class Task(val action: () -> Unit, var cancelled: Boolean = false) + } + + private class RecordingTransport : RemoteConfigFetchTransport { + val requests = mutableListOf() + private val completions = mutableListOf<(RemoteConfigFetchResponse) -> Unit>() + + override fun fetch( + request: RemoteConfigFetchRequest, + completion: (RemoteConfigFetchResponse) -> Unit, + ) { + requests += request + completions += completion + } + + fun complete(response: RemoteConfigFetchResponse) = completions.removeAt(0)(response) + } + + private class InMemoryFetchPolicyStore( + private val saveSucceeds: Boolean = true, + ) : RemoteConfigFetchPolicyStore { + private val states = mutableMapOf() + + override fun load(scope: RemoteConfigFetchPolicyScope): RemoteConfigFetchPolicyState? = states[scope] + + override fun save(scope: RemoteConfigFetchPolicyScope, state: RemoteConfigFetchPolicyState): Boolean { + if (saveSucceeds) states[scope] = state + return saveSucceeds + } + } + + private class InMemorySnapshotStore : RemoteConfigSnapshotStore { + private val states = mutableMapOf() + + override fun load(scope: RemoteConfigSnapshotScope): RemoteConfigSnapshotLoadResult = + states[scope]?.let { RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Found, it) } + ?: RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Missing) + + override fun save(scope: RemoteConfigSnapshotScope, state: RemoteConfigSnapshotState): Boolean { + states[scope] = state + return true + } + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportCoordinatorTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportCoordinatorTest.kt new file mode 100644 index 000000000..6d1c5166e --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportCoordinatorTest.kt @@ -0,0 +1,310 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.internal.logger.Logger +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 com.squareup.moshi.Moshi +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import okio.Buffer +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.security.MessageDigest +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * End-to-end proof that [RemoteConfigGatewayTransport] plugs into the existing fetch-policy engine: + * real [RemoteConfigFetchCoordinator], real [RemoteConfigSnapshotCore] with the strict wire parser, + * real HTTP over [MockWebServer]. Nothing between the socket and durable admission is stubbed. + */ +internal class RemoteConfigGatewayTransportCoordinatorTest { + private lateinit var server: MockWebServer + private lateinit var client: OkHttpClient + private val snapshotStore = InMemorySnapshotStore() + private val policyStore = InMemoryFetchPolicyStore() + private val scheduler = ManualScheduler() + + @Before + fun setUp() { + server = MockWebServer() + server.start() + client = OkHttpClient() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `server bytes reach durable admission unchanged`() { + val core = core() + val coordinator = coordinator(core) + coordinator.transitionTo(SCOPE) + val body = WIRE_BODY.toByteArray(Charsets.UTF_8) + server.enqueue(sessionResponse()) + server.enqueue(snapshotResponse(body, strongETag(body))) + + val result = fetch(coordinator) + + val fetched = result as RemoteConfigFetchResult.Fetched + assertTrue( + fetched.transition.status.name, + fetched.transition.status == RemoteConfigSnapshotTransitionStatus.Accepted || + fetched.transition.status == RemoteConfigSnapshotTransitionStatus.Activated, + ) + val admitted = requireNotNull(snapshotStore.states[SCOPE]?.candidate) + assertArrayEquals(body, admitted.canonicalBodyBytes) + assertEquals(strongETag(body), admitted.strongETag) + } + + @Test + fun `304 is recovered against the current head instead of re-admitting`() { + val core = core() + val coordinator = coordinator(core) + coordinator.transitionTo(SCOPE) + val body = WIRE_BODY.toByteArray(Charsets.UTF_8) + server.enqueue(sessionResponse()) + server.enqueue(snapshotResponse(body, strongETag(body))) + assertTrue(fetch(coordinator) is RemoteConfigFetchResult.Fetched) + server.takeRequest() + server.takeRequest() + + server.enqueue(MockResponse().setResponseCode(304).setHeader("ETag", strongETag(body))) + val result = fetch(coordinator) + + assertEquals(RemoteConfigFetchResult.NotModified, result) + // The session was reused, so the only new request is the conditional snapshot read. + val conditional = server.takeRequest() + assertEquals("/v3/remote-config-v2/snapshot", conditional.path) + assertEquals(strongETag(body), conditional.getHeader("If-None-Match")) + } + + @Test + fun `the bootstrapped project id is what a snapshot is admitted against`() { + // Nothing in the app configured 43: the session bootstrap alone establishes the project the + // snapshot must belong to, and this envelope names 42. + val core = core() + val coordinator = coordinator(core) + coordinator.transitionTo(SCOPE) + val body = WIRE_BODY.toByteArray(Charsets.UTF_8) + server.enqueue(sessionResponse(projectId = 43)) + server.enqueue(snapshotResponse(body, strongETag(body))) + + val result = fetch(coordinator) + + assertEquals( + RemoteConfigSnapshotTransitionStatus.Rejected, + (result as RemoteConfigFetchResult.Fetched).transition.status, + ) + assertNull(snapshotStore.states[SCOPE]?.candidate) + } + + @Test + fun `a later bootstrap that changes the project id is a typed failure, not a re-learn`() { + val core = core() + val coordinator = coordinator(core) + coordinator.transitionTo(SCOPE) + val body = WIRE_BODY.toByteArray(Charsets.UTF_8) + server.enqueue(sessionResponse()) + server.enqueue(snapshotResponse(body, strongETag(body))) + assertTrue(fetch(coordinator) is RemoteConfigFetchResult.Fetched) + + // A 401 drops the established session, so the next read re-bootstraps — and this time the + // gateway answers for a different project. + server.enqueue(MockResponse().setResponseCode(401)) + server.enqueue(sessionResponse(projectId = 43)) + + assertEquals(RemoteConfigFetchResult.ProjectMismatch, fetch(coordinator)) + // Snapshot, session, snapshot, session: no read was attempted on the refused session. + assertEquals(4, server.requestCount) + + // And the refusal arms the failure backoff, so an identify/logout loop cannot turn a + // misrouted gateway into one bootstrap round trip per call. Forced fetches bypass the + // minimum interval, never this gate. + val gated = fetch(coordinator, RemoteConfigFetchForceReason.Identify) + assertTrue(gated.toString(), gated is RemoteConfigFetchResult.Backoff) + assertEquals(4, server.requestCount) + } + + @Test + fun `a stalled gateway times out through the fetch policy`() { + val core = core() + val coordinator = coordinator(core) + coordinator.transitionTo(SCOPE) + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)) + + val latch = CountDownLatch(1) + var result: RemoteConfigFetchResult? = null + coordinator.fetch { fetchResult -> + result = fetchResult + latch.countDown() + } + scheduler.runNext() + + assertTrue(latch.await(AWAIT_SECONDS, TimeUnit.SECONDS)) + assertTrue(result is RemoteConfigFetchResult.TimedOut) + // The HTTP call is deliberately not cancelled on timeout: the coordinator fences the late + // response with a fresh admission token instead, so the request still reaches the server. + assertNotNull(server.takeRequest(AWAIT_SECONDS, TimeUnit.SECONDS)) + } + + private fun fetch( + coordinator: RemoteConfigFetchCoordinator, + forceReason: RemoteConfigFetchForceReason? = null, + ): RemoteConfigFetchResult { + val latch = CountDownLatch(1) + var result: RemoteConfigFetchResult? = null + coordinator.fetch(forceReason) { fetchResult -> + result = fetchResult + latch.countDown() + } + assertTrue("coordinator did not answer in time", latch.await(AWAIT_SECONDS, TimeUnit.SECONDS)) + return requireNotNull(result) + } + + private fun core() = RemoteConfigSnapshotCore(snapshotStore, bundledRelease = null) + + private fun coordinator(core: RemoteConfigSnapshotCore) = RemoteConfigFetchCoordinator( + core = core, + transport = transport(), + policyStore = policyStore, + clock = { CLOCK_MILLIS }, + random = { 0.5 }, + scheduler = scheduler, + policy = RemoteConfigFetchPolicy(minimumFetchIntervalMillis = 0, timeoutMillis = 5_000), + ) + + private fun transport() = RemoteConfigGatewayTransport( + callFactory = client, + baseUrlProvider = { server.url("/").toString() }, + identityProvider = { + RemoteConfigTransportIdentity(SCOPE, "project-key-secret", "QON_anon_a") + }, + clientContextProvider = { + RemoteConfigClientContext( + platform = "android", + appVersion = "1.2.3", + osVersion = "14", + sdkVersion = "9.7.0", + locale = "en_US", + deviceModel = "Pixel 8", + deviceInstalledAtSeconds = 1_577_836_800, + ) + }, + sessionStore = InMemorySessionStore(), + projectIds = RemoteConfigProjectIdRegistry(InMemoryProjectIdStore()), + clock = { CLOCK_MILLIS }, + moshi = Moshi.Builder().build(), + logger = SilentLogger(), + ) + + private fun sessionResponse(projectId: Long = 42) = MockResponse() + .setResponseCode(200) + .setBody( + "{\"session_token\":\"qrcs1.session-secret\",\"project_id\":$projectId," + + "\"environment\":\"prod\",\"expires_at\":\"2030-01-01T00:00:00Z\"}", + ) + + private fun snapshotResponse(body: ByteArray, etag: String) = MockResponse() + .setResponseCode(200) + .setHeader("ETag", etag) + .setBody(Buffer().write(body)) + + private class ManualScheduler : RemoteConfigFetchScheduler { + private val tasks = mutableListOf() + + override fun schedule(delayMillis: Long, action: () -> Unit): RemoteConfigFetchScheduledTask { + val task = Task(action) + synchronized(tasks) { tasks += task } + return RemoteConfigFetchScheduledTask { task.cancelled = true } + } + + fun runNext() { + val task = synchronized(tasks) { tasks.removeAt(0) } + if (!task.cancelled) task.action() + } + + private class Task(val action: () -> Unit, @Volatile var cancelled: Boolean = false) + } + + private class InMemorySessionStore : RemoteConfigSessionStore { + private val sessions = mutableMapOf() + + @Synchronized + override fun load(key: RemoteConfigSessionKey) = sessions[key] + + @Synchronized + override fun save(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession): Boolean { + sessions[key] = session + return true + } + + @Synchronized + override fun clear(key: RemoteConfigSessionKey): Boolean { + sessions.remove(key) + return true + } + } + + private class InMemoryFetchPolicyStore : RemoteConfigFetchPolicyStore { + private val states = mutableMapOf() + + @Synchronized + override fun load(scope: RemoteConfigFetchPolicyScope) = states[scope] + + @Synchronized + override fun save(scope: RemoteConfigFetchPolicyScope, state: RemoteConfigFetchPolicyState): Boolean { + states[scope] = state + return true + } + } + + private class InMemorySnapshotStore : RemoteConfigSnapshotStore { + val states = mutableMapOf() + + @Synchronized + override fun load(scope: RemoteConfigSnapshotScope): RemoteConfigSnapshotLoadResult = + states[scope]?.let { RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Found, it) } + ?: RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Missing) + + @Synchronized + override fun save(scope: RemoteConfigSnapshotScope, state: RemoteConfigSnapshotState): Boolean { + states[scope] = state + return true + } + } + + private class SilentLogger : Logger { + override fun error(message: String) = Unit + override fun warn(message: String) = Unit + override fun release(message: String) = Unit + override fun debug(message: String) = Unit + } + + private companion object { + const val AWAIT_SECONDS = 10L + const val CLOCK_MILLIS = 1_000_000L + val SCOPE = RemoteConfigSnapshotScope("project", "production", "canonical-user") + val WIRE_BODY = "{\"schema_version\":1,\"project_id\":42,\"environment_uid\":\"production\"," + + "\"release_uid\":\"release-1\",\"release_number\":1," + + "\"manifest_content_hash\":\"${"1".padStart(64, '0')}\"," + + "\"complete_key_set\":true,\"context_fingerprint\":\"${"a".repeat(64)}\"," + + "\"values\":{\"a\":{\"raw\":1,\"variation_uid\":\"variation-release-1\"," + + "\"apply_policy\":\"on_next_activate\",\"metadata\":null}}}" + + fun strongETag(body: ByteArray): String = MessageDigest.getInstance("SHA-256") + .digest(body) + .joinToString(prefix = "\"", postfix = "\"", separator = "") { byte -> "%02x".format(byte) } + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportTest.kt new file mode 100644 index 000000000..39786f34e --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportTest.kt @@ -0,0 +1,709 @@ +@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.qonversion.android.sdk.internal.storage.Cache +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okhttp3.mockwebserver.SocketPolicy +import okio.Buffer +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * HTTP contract tests for [RemoteConfigGatewayTransport] against a real [MockWebServer]. + * + * The SDK previously had no HTTP fixture at all (see `RedemptionManagerTest`, which hand-mocks + * Retrofit calls). This adapter is byte-exact by contract, so a real socket is the only fixture + * that can actually prove the bytes and headers on the wire. + */ +internal class RemoteConfigGatewayTransportTest { + private lateinit var server: MockWebServer + private lateinit var client: OkHttpClient + private lateinit var cache: MapCache + private lateinit var logger: RecordingLogger + private val clock = MutableClock(1_000_000) + private var identity: RemoteConfigTransportIdentity? = identityFor(SCOPE_A, USER_A) + private var clientContext = CLIENT_CONTEXT + + @Before + fun setUp() { + server = MockWebServer() + server.start() + client = OkHttpClient() + cache = MapCache() + logger = RecordingLogger() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `bootstrap and snapshot requests match the gateway contract exactly`() { + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + + val response = fetch(RemoteConfigFetchRequest()) + + val bootstrap = server.takeRequest() + assertEquals("POST", bootstrap.method) + assertEquals("/v3/remote-config-v2/session", bootstrap.path) + assertEquals("Bearer $PROJECT_TOKEN", bootstrap.getHeader("Authorization")) + assertEquals("application/json; charset=utf-8", bootstrap.getHeader("Content-Type")) + assertEquals("{\"user_uid\":\"$USER_A\"}", bootstrap.body.readUtf8()) + assertNull(bootstrap.getHeader(REMOTE_CONFIG_SESSION_HEADER)) + + val snapshot = server.takeRequest() + assertEquals("POST", snapshot.method) + assertEquals("/v3/remote-config-v2/snapshot", snapshot.path) + assertEquals("Bearer $PROJECT_TOKEN", snapshot.getHeader("Authorization")) + assertEquals(SESSION_TOKEN, snapshot.getHeader(REMOTE_CONFIG_SESSION_HEADER)) + assertNull(snapshot.getHeader("If-None-Match")) + assertEquals( + "{\"client_context\":{\"platform\":\"android\",\"app_version\":\"1.2.3\"," + + "\"os_version\":\"14\",\"sdk_version\":\"9.7.0\",\"locale\":\"en_US\"," + + "\"device_model\":\"Pixel 8\",\"device_installed_at\":1577836800}}", + snapshot.body.readUtf8(), + ) + assertTrue(response is RemoteConfigFetchResponse.Success) + } + + @Test + fun `identified scope obtains a host assertion and uses the identify bootstrap`() { + identity = identityFor(SCOPE_B, USER_B, externalUserId = "account-1") + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + val provider = QRemoteConfigIdentifyAssertionProvider { externalUserId, completion -> + assertEquals("account-1", externalUserId) + completion.onResult("payload.mac") + } + + assertTrue(fetch(RemoteConfigFetchRequest(), transport(assertionProvider = provider)) is RemoteConfigFetchResponse.Success) + + val bootstrap = server.takeRequest() + assertEquals("/v3/remote-config-v2/session/identify", bootstrap.path) + val bootstrapBody = bootstrap.body.readUtf8() + assertEquals("{\"assertion\":\"payload.mac\"}", bootstrapBody) + assertFalse(bootstrapBody.contains(USER_B)) + assertEquals("/v3/remote-config-v2/snapshot", server.takeRequest().path) + } + + @Test + fun `identified scope without an assertion fails closed without anonymous bootstrap`() { + identity = identityFor(SCOPE_B, USER_B, externalUserId = "account-1") + val provider = QRemoteConfigIdentifyAssertionProvider { _, completion -> completion.onResult(null) } + + assertTrue(fetch(RemoteConfigFetchRequest(), transport(assertionProvider = provider)) is RemoteConfigFetchResponse.Failure) + assertEquals(0, server.requestCount) + } + + @Test + fun `conditional validator is forwarded verbatim as If-None-Match`() { + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(MockResponse().setResponseCode(304).setHeader("ETag", SNAPSHOT_ETAG)) + + val response = fetch(RemoteConfigFetchRequest(ifNoneMatch = SNAPSHOT_ETAG)) + + server.takeRequest() + assertEquals(SNAPSHOT_ETAG, server.takeRequest().getHeader("If-None-Match")) + assertEquals(RemoteConfigFetchResponse.NotModified(SNAPSHOT_ETAG), response) + } + + @Test + fun `200 hands back the exact response bytes and etag`() { + // Deliberately non-canonical: padded whitespace, an escaped code point and a raw + // multi-byte character. Any re-encode or charset round trip changes these bytes and + // therefore the sha256 the ETag pins. + val body = ("{ \"schema_version\" : 1, \"note\":\"\\u00e9 café \\ud83d\\ude00\"," + + "\"trailing\": true }").toByteArray(Charsets.UTF_8) + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(body, SNAPSHOT_ETAG)) + + val response = fetch(RemoteConfigFetchRequest()) + + val success = response as RemoteConfigFetchResponse.Success + assertArrayEquals(body, success.body) + assertEquals(SNAPSHOT_ETAG, success.etag) + } + + @Test + fun `200 without a strong validator is a typed failure`() { + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) + } + + @Test + fun `snapshot 401 re-bootstraps once and retries successfully`() { + persistSession(KEY_A, "stale-token") + server.enqueue(MockResponse().setResponseCode(401).setBody("{\"error\":\"unauthorized\"}")) + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + + val response = fetch(RemoteConfigFetchRequest()) + + assertTrue(response is RemoteConfigFetchResponse.Success) + val first = server.takeRequest() + assertEquals("/v3/remote-config-v2/snapshot", first.path) + assertEquals("stale-token", first.getHeader(REMOTE_CONFIG_SESSION_HEADER)) + assertEquals("/v3/remote-config-v2/session", server.takeRequest().path) + val retry = server.takeRequest() + assertEquals("/v3/remote-config-v2/snapshot", retry.path) + assertEquals(SESSION_TOKEN, retry.getHeader(REMOTE_CONFIG_SESSION_HEADER)) + assertEquals(3, server.requestCount) + } + + @Test + fun `two consecutive 401s fail typed without looping`() { + persistSession(KEY_A, "stale-token") + server.enqueue(MockResponse().setResponseCode(401)) + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(MockResponse().setResponseCode(401)) + + val response = fetch(RemoteConfigFetchRequest()) + + assertEquals(RemoteConfigFetchResponse.Failure(statusCode = 401), response) + assertEquals(3, server.requestCount) + assertNull(store().load(KEY_A)) + } + + @Test + fun `a freshly minted session never re-bootstraps on 401`() { + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(MockResponse().setResponseCode(401)) + + val response = fetch(RemoteConfigFetchRequest()) + + assertEquals(RemoteConfigFetchResponse.Failure(statusCode = 401), response) + assertEquals(2, server.requestCount) + } + + @Test + fun `bootstrap 404 and 503 surface as typed failures`() { + server.enqueue(MockResponse().setResponseCode(404).setBody("{\"error\":\"not found\"}")) + assertEquals(RemoteConfigFetchResponse.Failure(statusCode = 404), fetch(RemoteConfigFetchRequest())) + + server.enqueue(MockResponse().setResponseCode(503).setBody("{\"error\":\"unavailable\"}")) + assertEquals(RemoteConfigFetchResponse.Failure(statusCode = 503), fetch(RemoteConfigFetchRequest())) + } + + @Test + fun `snapshot 404 and 503 surface as typed failures and 503 honours Retry-After`() { + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(MockResponse().setResponseCode(404)) + assertEquals(RemoteConfigFetchResponse.Failure(statusCode = 404), fetch(RemoteConfigFetchRequest())) + + server.enqueue(MockResponse().setResponseCode(503).setHeader("Retry-After", "7")) + assertEquals( + RemoteConfigFetchResponse.Failure(statusCode = 503, retryAfterMillis = 7_000), + fetch(RemoteConfigFetchRequest()), + ) + } + + @Test + fun `a broken connection is an untyped failure rather than a crash`() { + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)) + + assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) + } + + @Test + fun `session is persisted per identity scope and never reused after an identity change`() { + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + val transport = transport() + fetch(RemoteConfigFetchRequest(), transport) + server.takeRequest() + server.takeRequest() + // The session record plus the project id the bootstrap established. + val keysAfterFirstIdentity = cache.strings.keys.toSet() + assertEquals(2, keysAfterFirstIdentity.size) + keysAfterFirstIdentity.forEach { key -> + assertFalse(key, key.contains(USER_A)) + assertFalse(key, key.contains(PROJECT_TOKEN)) + } + + identity = identityFor(SCOPE_B, USER_B) + server.enqueue(sessionResponse(OTHER_SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + fetch(RemoteConfigFetchRequest(), transport) + + val bootstrap = server.takeRequest() + assertEquals("/v3/remote-config-v2/session", bootstrap.path) + assertEquals("{\"user_uid\":\"$USER_B\"}", bootstrap.body.readUtf8()) + val snapshot = server.takeRequest() + assertEquals(OTHER_SESSION_TOKEN, snapshot.getHeader(REMOTE_CONFIG_SESSION_HEADER)) + // Two session records, and still ONE project id record: it addresses the project, not the + // identity, so the second identity inherits the pin rather than re-learning it. + assertEquals(3, cache.strings.size) + assertEquals(SESSION_TOKEN, store().load(KEY_A)?.token) + assertEquals(OTHER_SESSION_TOKEN, store().load(KEY_B)?.token) + } + + @Test + fun `the bootstrapped project id is published with the snapshot it authorised`() { + server.enqueue(sessionResponse(SESSION_TOKEN, projectId = 77)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + + val success = fetch(RemoteConfigFetchRequest()) as RemoteConfigFetchResponse.Success + + // The admission check has no other source for it: nothing in this transport was configured + // with a project id. + assertEquals(77L, success.projectId) + assertEquals(77L, projectIdStore().load(SCOPE_A)) + } + + @Test + fun `a later bootstrap for a different project is refused instead of re-learned`() { + val transport = transport() + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + assertTrue(fetch(RemoteConfigFetchRequest(), transport) is RemoteConfigFetchResponse.Success) + + // The 401 forces a re-bootstrap, which now answers for another project. + server.enqueue(MockResponse().setResponseCode(401)) + server.enqueue(sessionResponse(OTHER_SESSION_TOKEN, projectId = 43)) + + assertEquals(RemoteConfigFetchResponse.ProjectMismatch, fetch(RemoteConfigFetchRequest(), transport)) + // No snapshot was read on the refused session, and it was not kept either. + assertEquals(4, server.requestCount) + assertNull(store().load(KEY_A)) + assertEquals(PROJECT_ID, projectIdStore().load(SCOPE_A)) + } + + @Test + fun `a persisted session for another project is refused and dropped`() { + assertTrue(projectIdStore().save(SCOPE_A, PROJECT_ID)) + persistSession(KEY_A, SESSION_TOKEN, projectId = 43) + + assertEquals(RemoteConfigFetchResponse.ProjectMismatch, fetch(RemoteConfigFetchRequest())) + assertEquals(0, server.requestCount) + assertNull(store().load(KEY_A)) + } + + @Test + fun `an established project id outlives the registry and store instances that learned it`() { + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + fetch(RemoteConfigFetchRequest()) + + // Brand new registry over a brand new store instance, i.e. what a cold start builds: the + // pin is read back from durable storage rather than re-learned from the next answer. + assertEquals(RemoteConfigProjectIdOutcome.Conflict, registry().establish(SCOPE_A, 43)) + assertEquals(RemoteConfigProjectIdOutcome.Established, registry().establish(SCOPE_A, PROJECT_ID)) + } + + @Test + fun `a pin that could not be persisted still fences this process`() { + val refusingStore = object : RemoteConfigProjectIdStore { + override fun load(scope: RemoteConfigSnapshotScope): Long? = throw IllegalStateException("boom") + override fun save(scope: RemoteConfigSnapshotScope, projectId: Long) = false + } + val registry = RemoteConfigProjectIdRegistry(refusingStore) + val transport = transport(projectIds = registry) + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + assertTrue(fetch(RemoteConfigFetchRequest(), transport) is RemoteConfigFetchResponse.Success) + + // Storage neither kept nor could re-read the pin, and it still cannot be re-learned. + assertEquals(RemoteConfigProjectIdOutcome.Conflict, registry.establish(SCOPE_A, 43)) + assertEquals(RemoteConfigProjectIdOutcome.Established, registry.establish(SCOPE_A, PROJECT_ID)) + } + + @Test + fun `a session without a usable project id is an ordinary failure, not a mismatch`() { + // Malformed, not misrouted: it says nothing about which project this installation reads, + // so neither the registry nor the transport may report the permanent addressing fault. + val registry = registry() + assertEquals(RemoteConfigProjectIdOutcome.Unusable, registry.establish(SCOPE_A, 0)) + assertEquals(RemoteConfigProjectIdOutcome.Established, registry.establish(SCOPE_A, PROJECT_ID)) + + server.enqueue( + MockResponse().setResponseCode(200).setBody( + "{\"session_token\":\"$SESSION_TOKEN\",\"project_id\":0," + + "\"environment\":\"prod\",\"expires_at\":\"2030-01-01T00:00:00Z\"}", + ), + ) + + assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) + assertEquals(1, server.requestCount) + } + + @Test + fun `a persisted session is reused without another bootstrap until it expires`() { + persistSession(KEY_A, SESSION_TOKEN, expiresAtMillis = clock.now + 3_600_000) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + + assertTrue(fetch(RemoteConfigFetchRequest()) is RemoteConfigFetchResponse.Success) + assertEquals(1, server.requestCount) + assertEquals("/v3/remote-config-v2/snapshot", server.takeRequest().path) + } + + @Test + fun `an expired persisted session is dropped and re-bootstrapped`() { + persistSession(KEY_A, "expired-token", expiresAtMillis = clock.now - 1) + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + + assertTrue(fetch(RemoteConfigFetchRequest()) is RemoteConfigFetchResponse.Success) + assertEquals("/v3/remote-config-v2/session", server.takeRequest().path) + assertEquals(SESSION_TOKEN, server.takeRequest().getHeader(REMOTE_CONFIG_SESSION_HEADER)) + } + + @Test + fun `device_installed_at is unchanged across a simulated logout`() { + // Logout mints a brand new anonymous uid and therefore a brand new snapshot scope. The + // device install date must not move with it: the server takes + // min(device_installed_at, client.created_at), so a moving value would make a long-time + // user look brand new to "new users" targeting. + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + fetch(RemoteConfigFetchRequest()) + server.takeRequest() + val beforeLogout = server.takeRequest().body.readUtf8() + + identity = identityFor(SCOPE_B, USER_B) + server.enqueue(sessionResponse(OTHER_SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + fetch(RemoteConfigFetchRequest()) + server.takeRequest() + val afterLogout = server.takeRequest().body.readUtf8() + + assertTrue(beforeLogout.contains("\"device_installed_at\":1577836800")) + assertEquals(beforeLogout, afterLogout) + } + + @Test + fun `neither the project token nor the session token is ever logged`() { + persistSession(KEY_A, "stale-token") + server.enqueue(MockResponse().setResponseCode(401)) + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(MockResponse().setResponseCode(401)) + fetch(RemoteConfigFetchRequest()) + + server.enqueue(MockResponse().setResponseCode(404)) + fetch(RemoteConfigFetchRequest()) + + assertTrue(logger.messages.isNotEmpty()) + logger.messages.forEach { message -> + assertFalse(message, message.contains(PROJECT_TOKEN)) + assertFalse(message, message.contains(SESSION_TOKEN)) + assertFalse(message, message.contains("stale-token")) + } + } + + @Test + fun `an unaddressable identity fails closed without touching the network`() { + identity = null + assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) + assertEquals(0, server.requestCount) + } + + @Test + fun `a client context the gateway would reject fails closed without touching the network`() { + clientContext = CLIENT_CONTEXT.copy(deviceInstalledAtSeconds = -1) + assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) + assertEquals(0, server.requestCount) + } + + @Test + fun `a bootstrap response with an unusable token is rejected without being persisted`() { + server.enqueue(sessionResponse(" padded-token ")) + + assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) + assertNull(store().load(KEY_A)) + // No snapshot may be attempted with a token we refused. + assertEquals(1, server.requestCount) + } + + @Test + fun `a token that is not a legal header value is refused instead of thrown`() { + // OkHttp throws IllegalArgumentException for a non-printable header value, and its message + // quotes the value — i.e. the credential — so this must never reach Request.Builder. + server.enqueue(sessionResponse("session\u0001secret")) + + assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) + assertEquals(1, server.requestCount) + assertNull(store().load(KEY_A)) + logger.messages.forEach { assertFalse(it, it.contains("secret")) } + } + + @Test + fun `a project token that is not a legal header value fails closed before any request`() { + identity = RemoteConfigTransportIdentity(SCOPE_A, "project\u0001secret", USER_A) + + assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) + assertEquals(0, server.requestCount) + } + + @Test + fun `a fractional or lowercase RFC3339 expiry is understood and persisted`() { + // Go marshals time.Time as RFC3339Nano with trailing zeros stripped, so the fraction is + // 0-9 digits wide; RFC 3339 also permits a lowercase t and z. + server.enqueue(sessionResponseWithExpiry("2030-01-01t00:00:00.123456789z")) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + + assertTrue(fetch(RemoteConfigFetchRequest()) is RemoteConfigFetchResponse.Success) + + assertEquals(1_893_456_000_123, store().load(KEY_A)?.expiresAtMillis) + } + + @Test + fun `an over-budget snapshot body is refused rather than allocated`() { + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(ByteArray(65) { '{'.code.toByte() }, SNAPSHOT_ETAG)) + + assertEquals( + RemoteConfigFetchResponse.Failure(), + fetch(RemoteConfigFetchRequest(), transport(maxSnapshotBodyBytes = 64)), + ) + } + + @Test + fun `an empty 200 body is a typed failure rather than an empty admission`() { + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(MockResponse().setResponseCode(200).setHeader("ETag", SNAPSHOT_ETAG)) + + assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) + } + + @Test + fun `one transport reuses its in-memory session across fetches`() { + val transport = transport(sessionStore = RefusingSessionStore()) + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + + assertTrue(fetch(RemoteConfigFetchRequest(), transport) is RemoteConfigFetchResponse.Success) + assertTrue(fetch(RemoteConfigFetchRequest(), transport) is RemoteConfigFetchResponse.Success) + + // Bootstrap, snapshot, snapshot: the second fetch reused the cached session even though + // the durable store refuses to keep anything. + assertEquals(3, server.requestCount) + assertEquals("/v3/remote-config-v2/session", server.takeRequest().path) + assertEquals("/v3/remote-config-v2/snapshot", server.takeRequest().path) + assertEquals("/v3/remote-config-v2/snapshot", server.takeRequest().path) + } + + @Test + fun `concurrent fetches on one transport each get exactly one answer`() { + server.dispatcher = PathDispatcher() + val transport = transport() + val start = CountDownLatch(1) + val done = CountDownLatch(THREADS) + val responses = Collections.synchronizedList(mutableListOf()) + repeat(THREADS) { + Thread { + start.await() + transport.fetch(RemoteConfigFetchRequest()) { response -> + responses += response + done.countDown() + } + }.start() + } + start.countDown() + + assertTrue(done.await(AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(THREADS, responses.size) + responses.forEach { assertTrue(it.toString(), it is RemoteConfigFetchResponse.Success) } + } + + @Test + fun `a session with an unparsable expiry serves the fetch but is not persisted`() { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + "{\"session_token\":\"$SESSION_TOKEN\",\"project_id\":42," + + "\"environment\":\"prod\",\"expires_at\":\"x\"}", + ), + ) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + + assertTrue(fetch(RemoteConfigFetchRequest()) is RemoteConfigFetchResponse.Success) + server.takeRequest() + assertEquals(SESSION_TOKEN, server.takeRequest().getHeader(REMOTE_CONFIG_SESSION_HEADER)) + assertNull(store().load(KEY_A)) + } + + private fun fetch( + request: RemoteConfigFetchRequest, + transport: RemoteConfigGatewayTransport = transport(), + ): RemoteConfigFetchResponse { + val latch = CountDownLatch(1) + var received: RemoteConfigFetchResponse? = null + transport.fetch(request) { response -> + received = response + latch.countDown() + } + assertTrue("transport did not answer in time", latch.await(AWAIT_SECONDS, TimeUnit.SECONDS)) + return requireNotNull(received) + } + + private fun transport( + sessionStore: RemoteConfigSessionStore = store(), + maxSnapshotBodyBytes: Long = REMOTE_CONFIG_SNAPSHOT_BODY_MAX_BYTES, + projectIds: RemoteConfigProjectIdRegistry = registry(), + assertionProvider: QRemoteConfigIdentifyAssertionProvider? = null, + ) = RemoteConfigGatewayTransport( + callFactory = client, + baseUrlProvider = { server.url("/").toString() }, + identityProvider = { identity }, + clientContextProvider = { clientContext }, + sessionStore = sessionStore, + projectIds = projectIds, + clock = clock, + identifyAssertionProvider = assertionProvider, + moshi = Moshi.Builder().build(), + logger = logger, + maxSnapshotBodyBytes = maxSnapshotBodyBytes, + ) + + private fun store() = PersistentRemoteConfigSessionStore(cache, Moshi.Builder().build()) + + private fun projectIdStore() = PersistentRemoteConfigProjectIdStore(cache) + + private fun registry() = RemoteConfigProjectIdRegistry(projectIdStore()) + + private fun persistSession( + key: RemoteConfigSessionKey, + token: String, + expiresAtMillis: Long = clock.now + 3_600_000, + projectId: Long = PROJECT_ID, + ) { + assertTrue( + store().save( + key, + RemoteConfigGatewaySession( + token = token, + projectId = projectId, + environment = "prod", + expiresAtMillis = expiresAtMillis, + ), + ), + ) + } + + private fun sessionResponse(token: String, projectId: Long = PROJECT_ID) = MockResponse() + .setResponseCode(200) + .setHeader("Cache-Control", "private, no-store") + .setBody( + "{\"session_token\":\"$token\",\"project_id\":$projectId,\"environment\":\"prod\"," + + "\"expires_at\":\"2030-01-01T00:00:00Z\"}", + ) + + private fun sessionResponseWithExpiry(expiresAt: String) = MockResponse() + .setResponseCode(200) + .setBody( + "{\"session_token\":\"$SESSION_TOKEN\",\"project_id\":42,\"environment\":\"prod\"," + + "\"expires_at\":\"$expiresAt\"}", + ) + + private fun snapshotResponse(body: ByteArray, etag: String) = MockResponse() + .setResponseCode(200) + .setHeader("ETag", etag) + .setHeader("Content-Type", "application/json") + .setBody(Buffer().write(body)) + + private fun identityFor( + scope: RemoteConfigSnapshotScope, + userUid: String, + externalUserId: String? = null, + ) = RemoteConfigTransportIdentity(scope, PROJECT_TOKEN, userUid, externalUserId) + + /** Answers by path so concurrent calls are not order-coupled. */ + private inner class PathDispatcher : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + if (request.path.orEmpty().endsWith("/session")) { + sessionResponse(SESSION_TOKEN) + } else { + snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG) + } + } + + private class RefusingSessionStore : RemoteConfigSessionStore { + override fun load(key: RemoteConfigSessionKey): RemoteConfigGatewaySession? = null + override fun save(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession) = false + override fun clear(key: RemoteConfigSessionKey) = false + } + + private class MutableClock(var now: Long) : RemoteConfigFetchClock { + override fun nowMillis(): Long = now + } + + private class RecordingLogger : Logger { + val messages = mutableListOf() + override fun error(message: String) { messages += message } + override fun warn(message: String) { messages += message } + override fun release(message: String) { messages += message } + override fun debug(message: String) { messages += message } + } + + private class MapCache : Cache { + val strings = mutableMapOf() + + override fun putInt(key: String, value: Int) = Unit + override fun getInt(key: String, defValue: Int): Int = defValue + override fun getBool(key: String, defValue: Boolean): Boolean = defValue + override fun putBool(key: String, value: Boolean) = Unit + override fun putFloat(key: String, value: Float) = Unit + override fun getFloat(key: String, defValue: Float): Float = defValue + override fun putLong(key: String, value: Long) = Unit + override fun getLong(key: String, defValue: Long): Long = defValue + override fun putString(key: String, value: String?) { strings[key] = value } + override fun getString(key: String, defValue: String?): String? = strings[key] ?: defValue + override fun remove(key: String) { strings.remove(key) } + + override fun updateStringsDurably(values: Map, removedKeys: Set): Boolean { + removedKeys.forEach(strings::remove) + strings.putAll(values) + return true + } + + override fun putObject(key: String, value: T, adapter: JsonAdapter) = Unit + override fun getObject(key: String, adapter: JsonAdapter): T? = null + } + + private companion object { + const val AWAIT_SECONDS = 10L + const val THREADS = 4 + const val PROJECT_TOKEN = "project-key-secret" + const val PROJECT_ID = 42L + const val SESSION_TOKEN = "qrcs1.session-secret" + const val OTHER_SESSION_TOKEN = "qrcs1.other-session-secret" + const val USER_A = "QON_anon_a" + const val USER_B = "QON_anon_b" + val SCOPE_A = RemoteConfigSnapshotScope("project", "env-production", USER_A) + val SCOPE_B = RemoteConfigSnapshotScope("project", "env-production", USER_B) + val KEY_A = RemoteConfigSessionKey(SCOPE_A, USER_A) + val KEY_B = RemoteConfigSessionKey(SCOPE_B, USER_B) + val SNAPSHOT_BODY = "{\"schema_version\":1}".toByteArray(Charsets.UTF_8) + const val SNAPSHOT_ETAG = "\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"" + val CLIENT_CONTEXT = RemoteConfigClientContext( + platform = "android", + appVersion = "1.2.3", + osVersion = "14", + sdkVersion = "9.7.0", + locale = "en_US", + deviceModel = "Pixel 8", + deviceInstalledAtSeconds = 1_577_836_800, + ) + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigReadGuardTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigReadGuardTest.kt new file mode 100644 index 000000000..9f98d35a6 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigReadGuardTest.kt @@ -0,0 +1,767 @@ +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 org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +internal class RemoteConfigReadGuardTest { + private val scopeA = RemoteConfigSnapshotScope("project", "production", "user-a") + private val scopeB = RemoteConfigSnapshotScope("project", "production", "user-b") + private val bundle = RemoteConfigScopedBundledRelease( + projectKey = "project", + environment = "production", + release = release("bundle", 1, "0"), + ) + + @Test + fun `release first read commits one pre-persisted activation without read-path IO`() { + val store = RecordingStore().apply { + states[scopeA] = RemoteConfigSnapshotState(candidate = release("candidate", 2, "2")) + } + val executor = ManualExecutor() + val telemetry = RecordingTelemetry() + val core = RemoteConfigSnapshotCore(store, bundle) + val guard = guard(core, PersistentRemoteConfigReadPreloader(store, executor), telemetry = telemetry) + var ready = 0 + + guard.transitionScopeBeforeSdkReady(scopeA) { ready++ } + assertEquals(0, store.loads) + executor.runAll() + assertEquals(1, ready) + assertEquals(1, store.loads) + assertEquals(1, store.saves) + val ioBeforeRead = store.loads to store.saves + + val held = guard.currentSnapshot() + assertEquals("candidate", held.releaseUid) + assertEquals("2", held.rawValue("key")?.value?.decodeToString()) + assertEquals(ioBeforeRead, store.loads to store.saves) + assertEquals("candidate", guard.currentSnapshot().releaseUid) + assertEquals(1, telemetry.events.count { it == RemoteConfigReadGuardEvent.ReadBeforeActivate }) + assertEquals(1, telemetry.events.count { it == RemoteConfigReadGuardEvent.ImplicitActivation }) + + core.acceptCandidate(scopeA, release("next", 3, "3")) + assertEquals("2", held.rawValue("key")?.value?.decodeToString()) + } + + @Test + fun `candidate fetched after preload is durably prepared and active on first read`() { + val active = release("active", 1, "1").withAdmissionToken(1) + val store = RecordingStore().apply { + states[scopeA] = RemoteConfigSnapshotState( + candidate = active, + active = active, + didActivate = true, + latestAdmissionToken = 1, + ) + } + val executor = ManualExecutor() + val telemetry = RecordingTelemetry() + val core = RemoteConfigSnapshotCore(store, bundle) + val guard = guard(core, PersistentRemoteConfigReadPreloader(store, executor), telemetry = telemetry) + guard.transitionScopeBeforeSdkReady(scopeA) + executor.runAll() + + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + core.acceptCandidate(scopeA, release("fetched", 2, "2")).status, + ) + assertEquals("fetched", store.states.getValue(scopeA).active?.releaseUid) + val ioBeforeRead = store.loads to store.saves + + assertEquals("fetched", guard.currentSnapshot().releaseUid) + assertEquals(ioBeforeRead, store.loads to store.saves) + assertEquals(1, telemetry.events.count { it == RemoteConfigReadGuardEvent.ImplicitActivation }) + assertEquals("fetched", guard.currentSnapshot().releaseUid) + } + + @Test + fun `debug first read reports the exact actionable assertion once and never activates`() { + val store = RecordingStore().apply { + states[scopeA] = RemoteConfigSnapshotState(candidate = release("candidate", 2, "2")) + } + val executor = ManualExecutor() + val assertions = mutableListOf() + val telemetry = RecordingTelemetry() + val guard = guard( + core = RemoteConfigSnapshotCore(store, bundle), + preloader = PersistentRemoteConfigReadPreloader(store, executor), + mode = RemoteConfigReadBuildMode.Debug, + assertion = RemoteConfigReadAssertion(assertions::add), + telemetry = telemetry, + ) + guard.transitionScopeBeforeSdkReady(scopeA) + executor.runAll() + val ioBeforeRead = store.loads to store.saves + + assertEquals("0", guard.currentSnapshot().rawValue("key")?.value?.decodeToString()) + assertEquals( + RemoteConfigSnapshotValueSource.Fallback, + guard.currentSnapshot().rawValue("key")?.source, + ) + assertEquals(listOf(REMOTE_CONFIG_READ_BEFORE_ACTIVATE_MESSAGE), assertions) + assertEquals(ioBeforeRead, store.loads to store.saves) + assertEquals(0, store.saves) + assertEquals(1, telemetry.events.count { it == RemoteConfigReadGuardEvent.ReadBeforeActivate }) + } + + @Test + fun `read before preload readiness pins bundle and late preload cannot change current`() { + val preloader = ControlledPreloader() + val telemetry = RecordingTelemetry() + val core = RemoteConfigSnapshotCore(RecordingStore(), bundle) + val guard = guard(core, preloader, telemetry = telemetry) + guard.transitionScopeBeforeSdkReady(scopeA) + + assertEquals("0", guard.currentSnapshot().rawValue("key")?.value?.decodeToString()) + preloader.complete( + 0, + readyPreload( + base = RemoteConfigSnapshotState(candidate = release("private", 2, "\"private\"")), + ), + ) + assertEquals("0", guard.currentSnapshot().rawValue("key")?.value?.decodeToString()) + assertEquals(1, telemetry.events.count { it == RemoteConfigReadGuardEvent.PreloadNotReady }) + assertEquals(1, telemetry.events.count { it == RemoteConfigReadGuardEvent.ReadBeforeActivate }) + } + + @Test + fun `concurrent first reads perform one implicit attempt and see one committed generation`() { + val store = RecordingStore().apply { + states[scopeA] = RemoteConfigSnapshotState(candidate = release("candidate", 2, "2")) + } + val executor = ManualExecutor() + val telemetry = RecordingTelemetry() + val guard = guard( + RemoteConfigSnapshotCore(store, bundle), + PersistentRemoteConfigReadPreloader(store, executor), + telemetry = telemetry, + ) + guard.transitionScopeBeforeSdkReady(scopeA) + executor.runAll() + val start = CountDownLatch(1) + val done = CountDownLatch(32) + val releases = Collections.synchronizedList(mutableListOf()) + repeat(32) { + Thread { + start.await() + releases += guard.currentSnapshot().releaseUid + done.countDown() + }.start() + } + + start.countDown() + assertTrue(done.await(5, TimeUnit.SECONDS)) + assertEquals(setOf("candidate"), releases.toSet()) + assertEquals(1, telemetry.events.count { it == RemoteConfigReadGuardEvent.ImplicitActivation }) + assertEquals(1, telemetry.events.count { it == RemoteConfigReadGuardEvent.ReadBeforeActivate }) + assertEquals(1, store.saves) + } + + @Test + fun `activation preparation failure preserves old active and is observable`() { + val old = release("active", 1, "1").withAdmissionToken(1) + val next = release("candidate", 2, "2").withAdmissionToken(2) + val store = RecordingStore().apply { + states[scopeA] = RemoteConfigSnapshotState( + candidate = next, + active = old, + didActivate = true, + latestAdmissionToken = 2, + ) + failSaves = true + } + val executor = ManualExecutor() + val telemetry = RecordingTelemetry() + val guard = guard( + RemoteConfigSnapshotCore(store, bundle), + PersistentRemoteConfigReadPreloader(store, executor), + telemetry = telemetry, + ) + guard.transitionScopeBeforeSdkReady(scopeA) + executor.runAll() + val ioBeforeRead = store.loads to store.saves + + assertEquals("active", guard.currentSnapshot().releaseUid) + assertEquals(ioBeforeRead, store.loads to store.saves) + assertEquals(1, telemetry.events.count { it == RemoteConfigReadGuardEvent.ActivationPersistenceFailed }) + assertEquals(0, telemetry.events.count { it == RemoteConfigReadGuardEvent.ImplicitActivation }) + } + + @Test + fun `successful fetch rearms first read after initial preparation persistence failure`() { + val old = release("active", 1, "1").withAdmissionToken(1) + val pending = release("pending", 2, "2").withAdmissionToken(2) + val store = RecordingStore().apply { + states[scopeA] = RemoteConfigSnapshotState( + candidate = pending, + active = old, + didActivate = true, + latestAdmissionToken = 2, + ) + failSaves = true + } + val executor = ManualExecutor() + val telemetry = RecordingTelemetry() + val core = RemoteConfigSnapshotCore(store, bundle) + val guard = guard(core, PersistentRemoteConfigReadPreloader(store, executor), telemetry = telemetry) + guard.transitionScopeBeforeSdkReady(scopeA) + executor.runAll() + store.failSaves = false + + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + core.acceptCandidate(scopeA, release("recovered", 3, "3")).status, + ) + assertEquals("recovered", store.states.getValue(scopeA).active?.releaseUid) + val ioBeforeRead = store.loads to store.saves + + assertEquals("recovered", guard.currentSnapshot().releaseUid) + assertEquals(ioBeforeRead, store.loads to store.saves) + assertEquals(1, telemetry.events.count { it == RemoteConfigReadGuardEvent.ImplicitActivation }) + } + + @Test + fun `implicit activation delivers one observer update outside guard locks`() { + val store = RecordingStore().apply { + states[scopeA] = RemoteConfigSnapshotState(candidate = release("candidate", 2, "2")) + } + val executor = ManualExecutor() + val core = RemoteConfigSnapshotCore(store, bundle) + lateinit var guard: RemoteConfigReadGuard + val observed = mutableListOf() + guard = guard(core, PersistentRemoteConfigReadPreloader(store, executor)) + core.addUpdateObserver { update -> + observed += update.snapshot.releaseUid + assertEquals("candidate", guard.currentSnapshot().releaseUid) + } + guard.transitionScopeBeforeSdkReady(scopeA) + executor.runAll() + + assertEquals("candidate", guard.currentSnapshot().releaseUid) + assertEquals(listOf("candidate"), observed) + assertEquals("candidate", guard.currentSnapshot().releaseUid) + assertEquals(listOf("candidate"), observed) + } + + @Test + fun `implicit activation observer can coordinate a concurrent snapshot read`() { + val store = RecordingStore().apply { + states[scopeA] = RemoteConfigSnapshotState(candidate = release("candidate", 2, "2")) + } + val executor = ManualExecutor() + val core = RemoteConfigSnapshotCore(store, bundle) + lateinit var guard: RemoteConfigReadGuard + var callbackCouldCoordinate = false + guard = guard(core, PersistentRemoteConfigReadPreloader(store, executor)) + core.addUpdateObserver { + val coordinated = CountDownLatch(1) + Thread { + if (guard.currentSnapshot().releaseUid == "candidate") coordinated.countDown() + }.start() + callbackCouldCoordinate = coordinated.await(1, TimeUnit.SECONDS) + } + guard.transitionScopeBeforeSdkReady(scopeA) + executor.runAll() + + assertEquals("candidate", guard.currentSnapshot().releaseUid) + assertTrue(callbackCouldCoordinate) + } + + @Test + fun `readiness callback can synchronously coordinate another scope transition`() { + val executor = ManualExecutor() + val guard = guard( + RemoteConfigSnapshotCore(RecordingStore(), bundle), + PersistentRemoteConfigReadPreloader(RecordingStore(), executor), + ) + val transitionFinished = CountDownLatch(1) + var callbackCouldCoordinate = false + guard.transitionScopeBeforeSdkReady(scopeA) { + Thread { + guard.transitionScopeBeforeSdkReady(scopeB) + transitionFinished.countDown() + }.start() + callbackCouldCoordinate = transitionFinished.await(1, TimeUnit.SECONDS) + } + + executor.runAll() + + assertTrue(callbackCouldCoordinate) + } + + @Test + fun `already active restart and immediate fetch never report implicit activation`() { + val active = release("active", 1, "1").withAdmissionToken(1) + val store = RecordingStore().apply { + states[scopeA] = RemoteConfigSnapshotState( + candidate = active, + active = active, + didActivate = true, + latestAdmissionToken = 1, + ) + } + val executor = ManualExecutor() + val telemetry = RecordingTelemetry() + val core = RemoteConfigSnapshotCore(store, bundle) + val guard = guard(core, PersistentRemoteConfigReadPreloader(store, executor), telemetry = telemetry) + guard.transitionScopeBeforeSdkReady(scopeA) + executor.runAll() + core.acceptCandidate(scopeA, release("immediate", 2, "2", immediate = true)) + + assertEquals("immediate", guard.currentSnapshot().releaseUid) + assertEquals(0, telemetry.events.count { it == RemoteConfigReadGuardEvent.ImplicitActivation }) + } + + @Test + fun `implicit activation opportunity is consumed once for the SDK lifetime across scopes`() { + val store = RecordingStore().apply { + states[scopeA] = RemoteConfigSnapshotState(candidate = release("candidate-a", 1, "1")) + states[scopeB] = RemoteConfigSnapshotState(candidate = release("candidate-b", 2, "2")) + } + val executor = ManualExecutor() + val telemetry = RecordingTelemetry() + val guard = guard( + RemoteConfigSnapshotCore(store, bundle), + PersistentRemoteConfigReadPreloader(store, executor), + telemetry = telemetry, + ) + guard.transitionScopeBeforeSdkReady(scopeA) + executor.runAll() + assertEquals("candidate-a", guard.currentSnapshot().releaseUid) + + guard.transitionScopeBeforeSdkReady(scopeB) + executor.runAll() + + assertEquals("0", guard.currentSnapshot().rawValue("key")?.value?.decodeToString()) + assertEquals(1, telemetry.events.count { it == RemoteConfigReadGuardEvent.ImplicitActivation }) + assertEquals(1, store.saves) + } + + @Test + fun `read between scope mutation and token binding cannot consume implicit activation`() { + val store = RecordingStore().apply { + states[scopeA] = RemoteConfigSnapshotState(candidate = release("candidate-a", 1, "1")) + states[scopeB] = RemoteConfigSnapshotState(candidate = release("candidate-b", 2, "2")) + } + val executor = ManualExecutor() + val scopeMutated = CountDownLatch(1) + val releaseBinding = CountDownLatch(1) + val transitionFinished = CountDownLatch(1) + val scopeMutationCalls = AtomicInteger() + val telemetry = RecordingTelemetry() + val core = RemoteConfigSnapshotCore( + store = store, + bundledRelease = bundle, + scopePreloadMutatedBeforeBinding = { + if (scopeMutationCalls.incrementAndGet() == 2) { + scopeMutated.countDown() + check(releaseBinding.await(5, TimeUnit.SECONDS)) + } + }, + ) + val guard = guard(core, PersistentRemoteConfigReadPreloader(store, executor), telemetry = telemetry) + guard.transitionScopeBeforeSdkReady(scopeA) + executor.runAll() + + Thread { + guard.transitionScopeBeforeSdkReady(scopeB) + transitionFinished.countDown() + }.start() + assertTrue(scopeMutated.await(5, TimeUnit.SECONDS)) + + assertEquals("0", guard.currentSnapshot().rawValue("key")?.value?.decodeToString()) + releaseBinding.countDown() + assertTrue(transitionFinished.await(5, TimeUnit.SECONDS)) + executor.runAll() + val ioBeforeRead = store.loads to store.saves + + assertEquals("candidate-b", guard.currentSnapshot().releaseUid) + assertEquals(ioBeforeRead, store.loads to store.saves) + assertEquals(1, telemetry.events.count { it == RemoteConfigReadGuardEvent.ImplicitActivation }) + } + + @Test + fun `read with null token during initial binding cannot consume implicit activation`() { + val store = RecordingStore().apply { + states[scopeA] = RemoteConfigSnapshotState(candidate = release("candidate", 1, "1")) + } + val executor = ManualExecutor() + val scopeMutated = CountDownLatch(1) + val releaseBinding = CountDownLatch(1) + val transitionFinished = CountDownLatch(1) + val telemetry = RecordingTelemetry() + val core = RemoteConfigSnapshotCore( + store = store, + bundledRelease = bundle, + scopePreloadMutatedBeforeBinding = { + scopeMutated.countDown() + check(releaseBinding.await(5, TimeUnit.SECONDS)) + }, + ) + val guard = guard(core, PersistentRemoteConfigReadPreloader(store, executor), telemetry = telemetry) + + Thread { + guard.transitionScopeBeforeSdkReady(scopeA) + transitionFinished.countDown() + }.start() + assertTrue(scopeMutated.await(5, TimeUnit.SECONDS)) + + assertEquals("0", guard.currentSnapshot().rawValue("key")?.value?.decodeToString()) + releaseBinding.countDown() + assertTrue(transitionFinished.await(5, TimeUnit.SECONDS)) + executor.runAll() + + assertEquals("candidate", guard.currentSnapshot().releaseUid) + assertEquals(1, telemetry.events.count { it == RemoteConfigReadGuardEvent.ImplicitActivation }) + } + + @Test + fun `explicit activation consumes the lifetime implicit activation opportunity`() { + val store = RecordingStore().apply { + states[scopeA] = RemoteConfigSnapshotState(candidate = release("candidate-a", 1, "1")) + states[scopeB] = RemoteConfigSnapshotState(candidate = release("candidate-b", 2, "2")) + } + val executor = ManualExecutor() + val telemetry = RecordingTelemetry() + val guard = guard( + RemoteConfigSnapshotCore(store, bundle), + PersistentRemoteConfigReadPreloader(store, executor), + telemetry = telemetry, + ) + guard.transitionScopeBeforeSdkReady(scopeA) + executor.runAll() + assertEquals(RemoteConfigSnapshotTransitionStatus.Activated, guard.activate().status) + + guard.transitionScopeBeforeSdkReady(scopeB) + executor.runAll() + + assertEquals("0", guard.currentSnapshot().rawValue("key")?.value?.decodeToString()) + assertEquals(0, telemetry.events.count { it == RemoteConfigReadGuardEvent.ImplicitActivation }) + assertEquals(1, store.saves) + } + + @Test + fun `prepared activation survives restart without another persistence write`() { + val store = RecordingStore().apply { + states[scopeA] = RemoteConfigSnapshotState(candidate = release("candidate", 2, "2")) + } + val firstExecutor = ManualExecutor() + val first = guard( + RemoteConfigSnapshotCore(store, bundle), + PersistentRemoteConfigReadPreloader(store, firstExecutor), + ) + first.transitionScopeBeforeSdkReady(scopeA) + firstExecutor.runAll() + assertEquals("candidate", first.currentSnapshot().releaseUid) + assertEquals(1, store.saves) + + val restartedExecutor = ManualExecutor() + val restartedTelemetry = RecordingTelemetry() + val restarted = guard( + RemoteConfigSnapshotCore(store, bundle), + PersistentRemoteConfigReadPreloader(store, restartedExecutor), + telemetry = restartedTelemetry, + ) + restarted.transitionScopeBeforeSdkReady(scopeA) + restartedExecutor.runAll() + val ioBeforeRead = store.loads to store.saves + + assertEquals("candidate", restarted.currentSnapshot().releaseUid) + assertEquals(ioBeforeRead, store.loads to store.saves) + assertEquals(1, store.saves) + assertEquals(0, restartedTelemetry.events.count { it == RemoteConfigReadGuardEvent.ImplicitActivation }) + } + + @Test + fun `late old-scope preload is fenced and can never expose another identity`() { + val preloader = ControlledPreloader() + val guard = guard(RemoteConfigSnapshotCore(RecordingStore(), bundle), preloader) + guard.transitionScopeBeforeSdkReady(scopeA) + guard.transitionScopeBeforeSdkReady(scopeB) + val baseB = RemoteConfigSnapshotState(candidate = release("private-b", 2, "\"b\"")) + preloader.complete(1, readyPreload(baseB)) + + assertEquals("private-b", guard.currentSnapshot().releaseUid) + val baseA = RemoteConfigSnapshotState(candidate = release("private-a", 3, "\"a\"")) + preloader.complete(0, readyPreload(baseA)) + assertEquals("private-b", guard.currentSnapshot().releaseUid) + assertEquals("\"b\"", guard.currentSnapshot().rawValue("key")?.value?.decodeToString()) + } + + @Test + fun `stale same-scope preload cannot overwrite a newer durable admission`() { + val initial = RemoteConfigSnapshotState(candidate = release("old", 1, "1")) + val store = InterleavingPreloadStore(scopeA, initial) + val executor = TrackingExecutor(expectedTasks = 3) + val core = RemoteConfigSnapshotCore(store, bundle) + val guard = guard(core, PersistentRemoteConfigReadPreloader(store, executor)) + val currentPreloadReady = CountDownLatch(1) + + guard.transitionScopeBeforeSdkReady(scopeA) + assertTrue(store.firstLoadEntered.await(5, TimeUnit.SECONDS)) + guard.transitionScopeBeforeSdkReady(scopeB) + guard.transitionScopeBeforeSdkReady(scopeA) { currentPreloadReady.countDown() } + assertTrue(currentPreloadReady.await(5, TimeUnit.SECONDS)) + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + core.acceptCandidate(scopeA, release("new", 2, "2")).status, + ) + assertEquals("new", store.currentState().active?.releaseUid) + + store.releaseFirstLoad.countDown() + assertTrue(executor.finished.await(5, TimeUnit.SECONDS)) + + assertEquals("new", store.currentState().active?.releaseUid) + assertEquals(2, store.saves.get()) + executor.shutdown() + } + + @Test + fun `same-generation preload cannot replace a newer durable admission`() { + val initial = RemoteConfigSnapshotState(candidate = release("old", 1, "1")) + val store = InterleavingPreloadStore(scopeA, initial) + val executor = TrackingExecutor(expectedTasks = 1) + val core = RemoteConfigSnapshotCore(store, bundle) + val telemetry = RecordingTelemetry() + val guard = guard( + core, + PersistentRemoteConfigReadPreloader(store, executor), + telemetry = telemetry, + ) + guard.transitionScopeBeforeSdkReady(scopeA) + assertTrue(store.firstLoadEntered.await(5, TimeUnit.SECONDS)) + + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + core.acceptCandidate(scopeA, release("new", 2, "2")).status, + ) + assertEquals("new", core.lastFetchedSnapshot()?.releaseUid) + store.releaseFirstLoad.countDown() + assertTrue(executor.finished.await(5, TimeUnit.SECONDS)) + + assertEquals("new", core.lastFetchedSnapshot()?.releaseUid) + assertEquals("new", store.currentState().candidate?.releaseUid) + assertEquals(1, store.saves.get()) + val ioBeforeRead = store.loads.get() to store.saves.get() + + assertEquals("new", guard.currentSnapshot().releaseUid) + assertEquals(ioBeforeRead, store.loads.get() to store.saves.get()) + assertEquals(1, telemetry.events.count { it == RemoteConfigReadGuardEvent.ImplicitActivation }) + executor.shutdown() + } + + @Test + fun `stale preload failure cannot advance readiness for the current scope`() { + val preloader = ControlledPreloader() + val readiness = mutableListOf() + val guard = guard(RemoteConfigSnapshotCore(RecordingStore(), bundle), preloader) + guard.transitionScopeBeforeSdkReady(scopeA) { readiness += "a" } + guard.transitionScopeBeforeSdkReady(scopeB) { readiness += "b" } + + preloader.complete( + 0, + RemoteConfigReadPreloadResult(RemoteConfigReadPreloadStatus.Failed), + ) + assertTrue(readiness.isEmpty()) + val baseB = RemoteConfigSnapshotState(candidate = release("private-b", 2, "\"b\"")) + preloader.complete(1, readyPreload(baseB)) + + assertEquals(listOf("b"), readiness) + assertEquals("private-b", guard.currentSnapshot().releaseUid) + } + + @Test + fun `after first read only explicit activation or immediate whole release changes current`() { + val store = RecordingStore().apply { + states[scopeA] = RemoteConfigSnapshotState(candidate = release("one", 1, "1")) + } + val executor = ManualExecutor() + val core = RemoteConfigSnapshotCore(store, bundle) + val guard = guard(core, PersistentRemoteConfigReadPreloader(store, executor)) + guard.transitionScopeBeforeSdkReady(scopeA) + executor.runAll() + assertEquals("one", guard.currentSnapshot().releaseUid) + + core.acceptCandidate(scopeA, release("two", 2, "2")) + assertEquals("one", guard.currentSnapshot().releaseUid) + assertEquals(RemoteConfigSnapshotTransitionStatus.Activated, guard.activate().status) + assertEquals("two", guard.currentSnapshot().releaseUid) + + core.acceptCandidate(scopeA, release("three", 3, "3", immediate = true)) + assertEquals("three", guard.currentSnapshot().releaseUid) + } + + @Test + fun `corrupt preload is observable and serves only matching pinned bundle`() { + val preloader = ControlledPreloader() + val telemetry = RecordingTelemetry() + val guard = guard( + RemoteConfigSnapshotCore(RecordingStore(), bundle), + preloader, + telemetry = telemetry, + ) + guard.transitionScopeBeforeSdkReady(scopeA) + preloader.complete( + 0, + RemoteConfigReadPreloadResult(RemoteConfigReadPreloadStatus.Corrupt), + ) + + assertEquals("0", guard.currentSnapshot().rawValue("key")?.value?.decodeToString()) + assertEquals(1, telemetry.events.count { it == RemoteConfigReadGuardEvent.PreloadCorrupt }) + assertEquals(1, telemetry.events.count { it == RemoteConfigReadGuardEvent.ReadBeforeActivate }) + } + + private fun guard( + core: RemoteConfigSnapshotCore, + preloader: RemoteConfigReadPreloader, + mode: RemoteConfigReadBuildMode = RemoteConfigReadBuildMode.Release, + assertion: RemoteConfigReadAssertion = RemoteConfigReadAssertion { throw AssertionError(it) }, + telemetry: RemoteConfigReadTelemetry = RemoteConfigReadTelemetry { }, + ) = RemoteConfigReadGuard(core, preloader, mode, assertion, telemetry) + + private fun readyPreload(base: RemoteConfigSnapshotState): RemoteConfigReadPreloadResult = + RemoteConfigReadPreloadResult( + status = RemoteConfigReadPreloadStatus.Ready, + baseState = base, + preparedActivationState = base.preparedActivationState(), + ) + + private fun release(uid: String, number: Long, raw: String, immediate: Boolean = false) = + RemoteConfigSnapshotRelease( + releaseUid = uid, + releaseNumber = number, + manifestContentHash = number.toString(16).padStart(64, '0'), + entries = listOf( + RemoteConfigSnapshotEntry.value( + key = "key", + rawValue = raw.encodeToByteArray(), + variationUid = "$uid-key", + applyPolicy = if (immediate) { + RemoteConfigSnapshotApplyPolicy.Immediate + } else { + RemoteConfigSnapshotApplyPolicy.OnNextActivate + }, + metadata = null, + ), + ), + ) + + private class RecordingStore : RemoteConfigSnapshotStore { + val states = mutableMapOf() + var loads = 0 + var saves = 0 + var failSaves = false + var loadStatus: RemoteConfigSnapshotLoadStatus? = null + + override fun load(scope: RemoteConfigSnapshotScope): RemoteConfigSnapshotLoadResult { + loads++ + loadStatus?.let { return RemoteConfigSnapshotLoadResult(it) } + return states[scope]?.let { + RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Found, it) + } ?: RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Missing) + } + + override fun save(scope: RemoteConfigSnapshotScope, state: RemoteConfigSnapshotState): Boolean { + saves++ + if (failSaves) return false + states[scope] = state + return true + } + } + + private class ManualExecutor : Executor { + private val tasks = mutableListOf() + override fun execute(command: Runnable) { + tasks += command + } + + fun runAll() { + while (tasks.isNotEmpty()) tasks.removeAt(0).run() + } + } + + private class TrackingExecutor(expectedTasks: Int) : Executor { + private val delegate = Executors.newCachedThreadPool() + val finished = CountDownLatch(expectedTasks) + + override fun execute(command: Runnable) { + delegate.execute { + try { + command.run() + } finally { + finished.countDown() + } + } + } + + fun shutdown() { + delegate.shutdownNow() + } + } + + private class InterleavingPreloadStore( + private val scope: RemoteConfigSnapshotScope, + initial: RemoteConfigSnapshotState, + ) : RemoteConfigSnapshotStore { + val firstLoadEntered = CountDownLatch(1) + val releaseFirstLoad = CountDownLatch(1) + val saves = AtomicInteger() + val loads = AtomicInteger() + private val stateLock = Any() + private var state = initial + + override fun load(scope: RemoteConfigSnapshotScope): RemoteConfigSnapshotLoadResult { + val captured = synchronized(stateLock) { state } + if (scope == this.scope && loads.incrementAndGet() == 1) { + firstLoadEntered.countDown() + check(releaseFirstLoad.await(5, TimeUnit.SECONDS)) + } + return if (scope == this.scope) { + RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Found, captured) + } else { + RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Missing) + } + } + + override fun save(scope: RemoteConfigSnapshotScope, state: RemoteConfigSnapshotState): Boolean { + check(scope == this.scope) + saves.incrementAndGet() + synchronized(stateLock) { this.state = state } + return true + } + + fun currentState(): RemoteConfigSnapshotState = synchronized(stateLock) { state } + } + + private class ControlledPreloader : RemoteConfigReadPreloader { + private val completions = mutableListOf<(RemoteConfigReadPreloadResult) -> Unit>() + override fun preload( + scope: RemoteConfigSnapshotScope, + prepareImplicitActivation: Boolean, + completion: (RemoteConfigReadPreloadResult) -> Unit, + ) { + completions += completion + } + + fun complete(index: Int, result: RemoteConfigReadPreloadResult) { + completions[index](result) + } + } + + private class RecordingTelemetry : RemoteConfigReadTelemetry { + val events = Collections.synchronizedList(mutableListOf()) + override fun report(event: RemoteConfigReadGuardEvent) { + events += event + } + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCoreTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCoreTest.kt new file mode 100644 index 000000000..0f7cff2a5 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCoreTest.kt @@ -0,0 +1,951 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.internal.storage.RemoteConfigSnapshotStore +import com.qonversion.android.sdk.internal.storage.RemoteConfigSnapshotLoadResult +import com.qonversion.android.sdk.internal.storage.RemoteConfigSnapshotLoadStatus +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import java.security.MessageDigest + +internal class RemoteConfigSnapshotCoreTest { + private val scopeA = RemoteConfigSnapshotScope("project", "production", "canonical-user-a") + private val scopeB = RemoteConfigSnapshotScope("project", "production", "canonical-user-b") + private val bundled = RemoteConfigScopedBundledRelease( + projectKey = "project", + environment = "production", + release = release("bundle", 1, mapOf("a" to "0", "b" to "0")), + ) + private val store = RecordingSnapshotStore() + private val core = RemoteConfigSnapshotCore(store, bundled) + + @Test + fun `candidate waits for activation and held snapshots never mutate`() { + core.setScope(scopeA) + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + core.acceptCandidate(scopeA, release("one", 1, mapOf("a" to "1"))).status, + ) + assertEquals("0", core.currentSnapshot().rawValue("a")?.value?.decodeToString()) + assertEquals("one", core.lastFetchedSnapshot()?.releaseUid) + + assertEquals(RemoteConfigSnapshotTransitionStatus.Activated, core.activate().status) + val held = core.currentSnapshot() + assertEquals("1", held.rawValue("a")?.value?.decodeToString()) + + core.acceptCandidate(scopeA, release("two", 2, mapOf("a" to "2"))) + core.activate() + + assertEquals("2", core.currentSnapshot().rawValue("a")?.value?.decodeToString()) + assertEquals("1", held.rawValue("a")?.value?.decodeToString()) + } + + @Test + fun `one immediate entry activates the entire release and emits one atomic update`() { + core.setScope(scopeA) + core.activate() + val observed = mutableListOf() + core.addUpdateObserver(observed::add) + val immediate = release( + uid = "immediate", + number = 2, + values = mapOf("a" to "1", "b" to "2"), + immediateKey = "a", + ) + + val result = core.acceptCandidate(scopeA, immediate) + + assertEquals(RemoteConfigSnapshotTransitionStatus.Activated, result.status) + assertEquals("1", core.currentSnapshot().rawValue("a")?.value?.decodeToString()) + assertEquals("2", core.currentSnapshot().rawValue("b")?.value?.decodeToString()) + assertEquals(1, observed.size) + assertEquals(setOf("a", "b"), observed.single().changedKeys) + assertEquals("immediate", observed.single().snapshot.releaseUid) + assertEquals("immediate", store.states.getValue(scopeA).active?.releaseUid) + } + + @Test + fun `logout and identity switch are a hard privacy boundary and late responses are ignored`() { + core.setScope(scopeA) + core.acceptCandidate(scopeA, release("private-a", 1, mapOf("a" to "\"private-a\""))) + core.activate() + + core.setScope(null) + assertEquals("0", core.currentSnapshot().rawValue("a")?.value?.decodeToString()) + assertNull(core.lastFetchedSnapshot()) + + core.setScope(scopeB) + val late = core.acceptCandidate( + scopeA, + release("late-a", 2, mapOf("a" to "\"must-not-leak\""), immediateKey = "a"), + ) + assertEquals(RemoteConfigSnapshotTransitionStatus.Ignored, late.status) + assertEquals("0", core.currentSnapshot().rawValue("a")?.value?.decodeToString()) + + core.setScope(scopeA) + assertEquals("\"private-a\"", core.currentSnapshot().rawValue("a")?.value?.decodeToString()) + } + + @Test + fun `candidate commit failure preserves admitted state and emits no update`() { + core.setScope(scopeA) + core.acceptCandidate(scopeA, release("one", 1, mapOf("a" to "1"))) + core.activate() + val observed = mutableListOf() + core.addUpdateObserver(observed::add) + store.failNextSave = true + + val result = core.acceptCandidate( + scopeA, + release("two", 2, mapOf("a" to "2"), immediateKey = "a"), + ) + + assertEquals(RemoteConfigSnapshotTransitionStatus.PersistenceFailed, result.status) + assertEquals("one", core.currentSnapshot().releaseUid) + assertEquals("one", core.lastFetchedSnapshot()?.releaseUid) + assertTrue(observed.isEmpty()) + } + + @Test + fun `activation commit failure preserves active candidate and previous without success`() { + core.setScope(scopeA) + core.acceptCandidate(scopeA, release("one", 1, mapOf("a" to "1"))) + core.activate() + core.acceptCandidate(scopeA, release("two", 2, mapOf("a" to "2"))) + val before = core.currentSnapshot() + val observed = mutableListOf() + core.addUpdateObserver(observed::add) + store.failNextSave = true + + val result = core.activate() + + assertEquals(RemoteConfigSnapshotTransitionStatus.PersistenceFailed, result.status) + assertFalse(result.changed) + assertEquals("one", core.currentSnapshot().releaseUid) + assertEquals("two", core.lastFetchedSnapshot()?.releaseUid) + assertArrayEquals(before.rawValue("a")?.value, core.currentSnapshot().rawValue("a")?.value) + assertTrue(observed.isEmpty()) + } + + @Test + fun `server release number is a rollback floor while equal follows local admission order`() { + core.setScope(scopeA) + core.acceptCandidate(scopeA, release("newest", 3, mapOf("a" to "3"))) + + assertEquals( + RemoteConfigSnapshotTransitionStatus.Ignored, + core.acceptCandidate( + scopeA, + release("rollback", 2, mapOf("a" to "2"), immediateKey = "a"), + ).status, + ) + assertEquals("newest", core.lastFetchedSnapshot()?.releaseUid) + + assertEquals( + RemoteConfigSnapshotTransitionStatus.Activated, + core.acceptCandidate( + scopeA, + release("same-number-newest-request", 3, mapOf("a" to "4"), immediateKey = "a"), + ).status, + ) + assertEquals("same-number-newest-request", core.currentSnapshot().releaseUid) + } + + @Test + fun `last fetched snapshot after activation retains the actual previous decode tier`() { + core.setScope(scopeA) + core.acceptCandidate(scopeA, release("one", 1, mapOf("a" to "{\"value\":1}"))) + core.activate() + core.acceptCandidate(scopeA, release("two", 2, mapOf("a" to "\"wrong-shape\""))) + core.activate() + + val resolved = core.lastFetchedSnapshot()?.value("a") { raw -> + raw.decodeToString().takeIf { it.startsWith("{") } + } + + assertEquals(RemoteConfigSnapshotValueSource.Cache, resolved?.source) + assertEquals("{\"value\":1}", resolved?.value) + } + + @Test + fun `one failing observer cannot block committed result or other observers`() { + core.setScope(scopeA) + val observed = mutableListOf() + core.addUpdateObserver { error("observer failure") } + core.addUpdateObserver { update -> observed += update.snapshot.releaseUid } + + val result = core.acceptCandidate( + scopeA, + release("immediate", 1, mapOf("a" to "1"), immediateKey = "a"), + ) + + assertEquals(RemoteConfigSnapshotTransitionStatus.Activated, result.status) + assertEquals("immediate", core.currentSnapshot().releaseUid) + assertEquals(listOf("immediate"), observed) + } + + @Test + fun `scope change waits for in flight delivery so no private callback arrives after logout`() { + core.setScope(scopeA) + val firstObserverStarted = CountDownLatch(1) + val releaseFirstObserver = CountDownLatch(1) + val scopeChangeFinished = CountDownLatch(1) + val callbackAfterLogout = AtomicBoolean(false) + core.addUpdateObserver { + firstObserverStarted.countDown() + releaseFirstObserver.await(2, TimeUnit.SECONDS) + } + core.addUpdateObserver { + if (scopeChangeFinished.count == 0L) callbackAfterLogout.set(true) + } + val acceptThread = Thread { + core.acceptCandidate( + scopeA, + release("private", 1, mapOf("a" to "\"private\""), immediateKey = "a"), + ) + } + acceptThread.start() + assertTrue(firstObserverStarted.await(2, TimeUnit.SECONDS)) + val logoutThread = Thread { + core.setScope(null) + scopeChangeFinished.countDown() + } + logoutThread.start() + + val logoutFinishedBeforeDelivery = scopeChangeFinished.await(250, TimeUnit.MILLISECONDS) + releaseFirstObserver.countDown() + acceptThread.join(2_000) + logoutThread.join(2_000) + + assertFalse(logoutFinishedBeforeDelivery) + assertFalse(callbackAfterLogout.get()) + assertEquals("0", core.currentSnapshot().rawValue("a")?.value?.decodeToString()) + } + + @Test + fun `transient scope load failure cannot overwrite durable state and same scope retries`() { + store.states[scopeA] = RemoteConfigSnapshotState( + candidate = release("private", 1, mapOf("a" to "\"private\"")), + active = release("private", 1, mapOf("a" to "\"private\"")), + didActivate = true, + ) + store.failNextLoads = 2 + + core.setScope(scopeA) + assertEquals("0", core.currentSnapshot().rawValue("a")?.value?.decodeToString()) + + val activation = core.activate() + assertEquals(RemoteConfigSnapshotTransitionStatus.PersistenceFailed, activation.status) + assertTrue(store.savedStates.isEmpty()) + + core.setScope(scopeA) + assertEquals("private", core.currentSnapshot().releaseUid) + assertEquals("\"private\"", core.currentSnapshot().rawValue("a")?.value?.decodeToString()) + } + + @Test + fun `activation reports unchanged when a new release has no effective diff`() { + core.setScope(scopeA) + val observed = mutableListOf() + core.addUpdateObserver(observed::add) + val emptyRelease = RemoteConfigSnapshotRelease( + releaseUid = "empty", + releaseNumber = 1, + manifestContentHash = hash(1), + entries = emptyList(), + ) + core.acceptCandidate(scopeA, emptyRelease) + + val result = core.activate() + + assertEquals(RemoteConfigSnapshotTransitionStatus.Activated, result.status) + assertFalse(result.changed) + assertTrue(result.update?.changedKeys?.isEmpty() == true) + assertTrue(observed.isEmpty()) + } + + @Test + fun `bundled fallback is available only in its project and environment scope`() { + core.setScope(RemoteConfigSnapshotScope("project", "sandbox", "canonical-user")) + assertNull(core.currentSnapshot().rawValue("a")) + + core.setScope(RemoteConfigSnapshotScope("other-project", "production", "canonical-user")) + assertNull(core.currentSnapshot().rawValue("a")) + + core.setScope(scopeA) + assertEquals("0", core.currentSnapshot().rawValue("a")?.value?.decodeToString()) + } + + @Test + fun `slow observer never blocks snapshot reads on the state lock`() { + core.setScope(scopeA) + val observerStarted = CountDownLatch(1) + val releaseObserver = CountDownLatch(1) + val snapshotRead = CountDownLatch(1) + core.addUpdateObserver { + observerStarted.countDown() + releaseObserver.await(2, TimeUnit.SECONDS) + } + val acceptThread = Thread { + core.acceptCandidate( + scopeA, + release("private", 1, mapOf("a" to "\"private\""), immediateKey = "a"), + ) + } + acceptThread.start() + assertTrue(observerStarted.await(2, TimeUnit.SECONDS)) + val readerThread = Thread { + core.currentSnapshot() + snapshotRead.countDown() + } + readerThread.start() + + val readCompletedWhileObserverWasBlocked = snapshotRead.await(250, TimeUnit.MILLISECONDS) + releaseObserver.countDown() + acceptThread.join(2_000) + readerThread.join(2_000) + + assertTrue(readCompletedWhileObserverWasBlocked) + } + + @Test + fun `reentrant activation callbacks preserve durable commit order for every observer`() { + core.setScope(scopeA) + val observedBySecond = mutableListOf() + core.addUpdateObserver { update -> + if (update.snapshot.releaseUid == "one") { + core.acceptCandidate( + scopeA, + release("two", 2, mapOf("a" to "2"), immediateKey = "a"), + ) + } + } + core.addUpdateObserver { update -> observedBySecond += update.snapshot.releaseUid } + + core.acceptCandidate( + scopeA, + release("one", 1, mapOf("a" to "1"), immediateKey = "a"), + ) + + assertEquals(listOf("one", "two"), observedBySecond) + assertEquals("two", core.currentSnapshot().releaseUid) + } + + @Test + fun `concurrent commit during slow delivery preserves FIFO observer order`() { + core.setScope(scopeA) + val firstDeliveryStarted = CountDownLatch(1) + val releaseFirstDelivery = CountDownLatch(1) + val secondCommitFinished = CountDownLatch(1) + val observedBySecond = mutableListOf() + store.saveObserver = { savedState -> + if (savedState.active?.releaseUid == "two") secondCommitFinished.countDown() + } + core.addUpdateObserver { update -> + if (update.snapshot.releaseUid == "one") { + firstDeliveryStarted.countDown() + releaseFirstDelivery.await(2, TimeUnit.SECONDS) + } + } + core.addUpdateObserver { update -> observedBySecond += update.snapshot.releaseUid } + val firstThread = Thread { + core.acceptCandidate( + scopeA, + release("one", 1, mapOf("a" to "1"), immediateKey = "a"), + ) + } + firstThread.start() + assertTrue(firstDeliveryStarted.await(2, TimeUnit.SECONDS)) + val secondThread = Thread { + core.acceptCandidate( + scopeA, + release("two", 2, mapOf("a" to "2"), immediateKey = "a"), + ) + } + secondThread.start() + assertTrue(secondCommitFinished.await(2, TimeUnit.SECONDS)) + + releaseFirstDelivery.countDown() + firstThread.join(2_000) + secondThread.join(2_000) + + assertEquals(listOf("one", "two"), observedBySecond) + assertEquals("two", core.currentSnapshot().releaseUid) + } + + @Test + fun `delivery enqueued at the empty handoff is never stranded`() { + val raceStore = RecordingSnapshotStore() + val injectAtEmpty = AtomicBoolean(true) + lateinit var raceCore: RemoteConfigSnapshotCore + raceCore = RemoteConfigSnapshotCore( + raceStore, + bundled, + deliveryQueueObservedEmpty = { + if (injectAtEmpty.compareAndSet(true, false)) { + raceCore.acceptCandidate( + scopeA, + release("two", 2, mapOf("a" to "2"), immediateKey = "a"), + ) + } + }, + ) + val observed = mutableListOf() + raceCore.setScope(scopeA) + raceCore.addUpdateObserver { update -> observed += update.snapshot.releaseUid } + + raceCore.acceptCandidate( + scopeA, + release("one", 1, mapOf("a" to "1"), immediateKey = "a"), + ) + + assertEquals(listOf("one", "two"), observed) + assertEquals("two", raceCore.currentSnapshot().releaseUid) + } + + @Test + fun `wire admission rejects one malformed item without any partial durable candidate`() { + core.setScope(scopeA) + val malformed = wireBody( + releaseUid = "wire", + releaseNumber = 1, + values = "\"good\":${wireItem("1")},\"bad\":${wireItem("{\"x\":1,\"x\":2}")}", + ) + + val result = core.admitWire( + admissionToken = requireNotNull(core.beginAdmission(scopeA)), + body = malformed.encodeToByteArray(), + etag = strongETag(malformed.encodeToByteArray()), + ) + + assertEquals(RemoteConfigSnapshotTransitionStatus.Rejected, result.status) + assertNull(core.lastFetchedSnapshot()) + assertTrue(store.savedStates.isEmpty()) + } + + @Test + fun `wire admission fences exact identity project and environment`() { + core.setScope(scopeA) + val body = wireBody("wire", 1, "\"a\":${wireItem("1")}").encodeToByteArray() + assertNull(core.beginAdmission(scopeB)) + + // The environment is the admitting scope's own, so an envelope for another one is refused + // without anyone having to restate it; the project id is the one the session established. + val staging = wireBody("wire", 1, "\"a\":${wireItem("1")}", environmentUid = "staging") + .encodeToByteArray() + assertEquals( + RemoteConfigSnapshotTransitionStatus.Rejected, + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), + staging, + strongETag(staging), + ).status, + ) + assertEquals( + RemoteConfigSnapshotTransitionStatus.Rejected, + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), + body, + strongETag(body), + projectId = 43, + ).status, + ) + assertTrue(store.savedStates.isEmpty()) + } + + @Test + fun `complete wire admission tombstones missing active keys and never resurrects Previous`() { + core.setScope(scopeA) + core.acceptCandidate(scopeA, release("one", 1, mapOf("removed" to "{\"shape\":1}"))) + core.activate() + val body = wireBody( + releaseUid = "wire-two", + releaseNumber = 2, + values = "\"kept\":${wireItem("2", immediate = true)}", + ).encodeToByteArray() + + val result = core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), + body, + strongETag(body), + ) + + assertEquals(RemoteConfigSnapshotTransitionStatus.Activated, result.status) + val admitted = store.states.getValue(scopeA).active + assertTrue(admitted?.entry("removed")?.isTombstone == true) + assertNull(core.currentSnapshot().value("removed") { raw -> raw.decodeToString() }) + assertEquals("2", core.currentSnapshot().rawValue("kept")?.value?.decodeToString()) + assertArrayEquals(body, admitted?.canonicalBodyBytes) + assertEquals(strongETag(body), admitted?.strongETag) + } + + @Test + fun `wire admission rejects a parsed response after scope changes away and back`() { + val raceStore = RecordingSnapshotStore() + val body = wireBody("wire", 1, "\"a\":${wireItem("1")}").encodeToByteArray() + val etag = strongETag(body) + val expectation = wireExpectation() + val parsed = requireNotNull(RemoteConfigSnapshotEnvelopeParser().parse(body, etag, expectation)) + val parserStarted = CountDownLatch(1) + val releaseParser = CountDownLatch(1) + val blockingParser = RemoteConfigSnapshotEnvelopeDecoder { _, _, _ -> + parserStarted.countDown() + releaseParser.await(2, TimeUnit.SECONDS) + parsed + } + val raceCore = RemoteConfigSnapshotCore(raceStore, bundled, blockingParser) + raceCore.setScope(scopeA) + val admissionToken = requireNotNull(raceCore.beginAdmission(scopeA)) + val result = AtomicReference() + val admissionThread = Thread { + result.set(raceCore.admitWire(admissionToken, body, etag)) + } + admissionThread.start() + assertTrue(parserStarted.await(2, TimeUnit.SECONDS)) + + raceCore.setScope(scopeB) + raceCore.setScope(scopeA) + releaseParser.countDown() + admissionThread.join(2_000) + + assertEquals(RemoteConfigSnapshotTransitionStatus.Rejected, result.get()?.status) + assertTrue(raceStore.savedStates.isEmpty()) + } + + @Test + fun `admission token is opaque to another core and admits only the served project`() { + val firstStore = RecordingSnapshotStore() + val secondStore = RecordingSnapshotStore() + val firstCore = RemoteConfigSnapshotCore(firstStore, bundled) + val secondCore = RemoteConfigSnapshotCore(secondStore, bundled) + firstCore.setScope(scopeA) + secondCore.setScope(scopeA) + val token = requireNotNull(firstCore.beginAdmission(scopeA)) + val validBody = wireBody("wire-a", 7, "\"a\":${wireItem("1")}").encodeToByteArray() + + assertEquals( + RemoteConfigSnapshotTransitionStatus.Rejected, + secondCore.admitWire(token, validBody, strongETag(validBody)).status, + ) + assertTrue(secondStore.savedStates.isEmpty()) + + // The project the response was served for travels with the response: a body naming another + // project than the session it arrived on cannot be admitted, even by the core and scope the + // token was issued for. + assertEquals( + RemoteConfigSnapshotTransitionStatus.Rejected, + firstCore.admitWire( + requireNotNull(firstCore.beginAdmission(scopeA)), + validBody, + strongETag(validBody), + projectId = 43, + ).status, + ) + assertTrue(firstStore.savedStates.isEmpty()) + } + + @Test + fun `only latest issued token can admit and superseded Immediate emits no callback`() { + val immediateBody = wireBody( + "superseded-immediate", + 7, + "\"a\":${wireItem("1", immediate = true)}", + ).encodeToByteArray() + val expectation = wireExpectation() + val parsed = requireNotNull( + RemoteConfigSnapshotEnvelopeParser().parse( + immediateBody, + strongETag(immediateBody), + expectation, + ), + ) + val parserStarted = CountDownLatch(1) + val releaseParser = CountDownLatch(1) + val blockingParser = RemoteConfigSnapshotEnvelopeDecoder { _, _, _ -> + parserStarted.countDown() + releaseParser.await(2, TimeUnit.SECONDS) + parsed + } + val raceStore = RecordingSnapshotStore() + val raceCore = RemoteConfigSnapshotCore(raceStore, bundled, blockingParser) + raceCore.setScope(scopeA) + val observed = mutableListOf() + raceCore.addUpdateObserver(observed::add) + val superseded = requireNotNull(raceCore.beginAdmission(scopeA)) + val result = AtomicReference() + val admissionThread = Thread { + result.set(raceCore.admitWire(superseded, immediateBody, strongETag(immediateBody))) + } + admissionThread.start() + assertTrue(parserStarted.await(2, TimeUnit.SECONDS)) + + requireNotNull(raceCore.beginAdmission(scopeA)) + releaseParser.countDown() + admissionThread.join(2_000) + + assertEquals(RemoteConfigSnapshotTransitionStatus.Rejected, result.get()?.status) + assertTrue(observed.isEmpty()) + assertNull(raceCore.lastFetchedSnapshot()) + assertTrue(raceStore.savedStates.isEmpty()) + } + + @Test + fun `Immediate committed before a newer admission but delivered after it emits no callback`() { + val raceStore = RecordingSnapshotStore() + val raceCore = RemoteConfigSnapshotCore(raceStore, bundled) + val firstDeliveryStarted = CountDownLatch(1) + val releaseFirstDelivery = CountDownLatch(1) + val supersededCommitFinished = CountDownLatch(1) + val observed = mutableListOf() + raceStore.saveObserver = { savedState -> + if (savedState.active?.releaseUid == "superseded") { + supersededCommitFinished.countDown() + } + } + raceCore.setScope(scopeA) + raceCore.addUpdateObserver { update -> + if (update.snapshot.releaseUid == "blocking") { + firstDeliveryStarted.countDown() + releaseFirstDelivery.await(2, TimeUnit.SECONDS) + } + } + raceCore.addUpdateObserver { update -> observed += update.snapshot.releaseUid } + + val blockingDeliveryThread = Thread { + raceCore.acceptCandidate( + scopeA, + release("blocking", 1, mapOf("a" to "1"), immediateKey = "a"), + ) + } + blockingDeliveryThread.start() + assertTrue(firstDeliveryStarted.await(2, TimeUnit.SECONDS)) + + val supersededBody = wireBody( + "superseded", + 2, + "\"a\":${wireItem("2", immediate = true)}", + ).encodeToByteArray() + val supersededToken = requireNotNull(raceCore.beginAdmission(scopeA)) + val supersededResult = AtomicReference() + val supersededThread = Thread { + supersededResult.set( + raceCore.admitWire( + supersededToken, + supersededBody, + strongETag(supersededBody), + ), + ) + } + supersededThread.start() + assertTrue(supersededCommitFinished.await(2, TimeUnit.SECONDS)) + + requireNotNull(raceCore.beginAdmission(scopeA)) + releaseFirstDelivery.countDown() + blockingDeliveryThread.join(2_000) + supersededThread.join(2_000) + + assertEquals(RemoteConfigSnapshotTransitionStatus.Activated, supersededResult.get()?.status) + assertEquals(listOf("blocking"), observed) + assertEquals("superseded", raceCore.currentSnapshot().releaseUid) + } + + @Test + fun `newer request cannot roll server release below candidate or active floor`() { + core.setScope(scopeA) + val current = wireBody("release-seven", 7, "\"a\":${wireItem("7")}").encodeToByteArray() + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), + current, + strongETag(current), + ).status, + ) + val rollback = wireBody("release-six", 6, "\"a\":${wireItem("6")}").encodeToByteArray() + + assertEquals( + RemoteConfigSnapshotTransitionStatus.Rejected, + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), + rollback, + strongETag(rollback), + ).status, + ) + assertEquals("release-seven", core.lastFetchedSnapshot()?.releaseUid) + + core.activate() + val secondRollback = wireBody("release-five", 5, "\"a\":${wireItem("5")}").encodeToByteArray() + assertEquals( + RemoteConfigSnapshotTransitionStatus.Rejected, + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), + secondRollback, + strongETag(secondRollback), + ).status, + ) + assertEquals("release-seven", core.currentSnapshot().releaseUid) + } + + @Test + fun `a rotated targeting context is admitted, it is an opaque per response tag`() { + // The fingerprint hashes mutable targeting context (app/OS version, locale, purchases, + // properties), so it rotates for reasons that have nothing to do with identity: an app + // update or a language switch changes it. Refusing the new value would freeze this + // identity's config until logout. Identity isolation is the session's job. + core.setScope(scopeA) + val firstContext = "a".repeat(64) + val secondContext = "b".repeat(64) + val first = wireBody( + releaseUid = "same-release-first-context", + releaseNumber = 7, + values = "\"a\":${wireItem("{\"shape\":1}")}", + contextFingerprint = firstContext, + ).encodeToByteArray() + val second = wireBody( + releaseUid = "same-release-second-context", + releaseNumber = 7, + values = "\"a\":${wireItem("{\"shape\":2}")}", + contextFingerprint = secondContext, + ).encodeToByteArray() + + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), + first, + strongETag(first), + ).status, + ) + core.activate() + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), + second, + strongETag(second), + ).status, + ) + + assertEquals("same-release-second-context", core.lastFetchedSnapshot()?.releaseUid) + assertEquals(secondContext, store.states.getValue(scopeA).candidate?.contextFingerprint) + assertEquals(7L, store.states.getValue(scopeA).candidate?.releaseNumber) + } + + @Test + fun `request start token rejects old response after new and accepts new response after old`() { + core.setScope(scopeA) + val oldToken = requireNotNull(core.beginAdmission(scopeA)) + val newToken = requireNotNull(core.beginAdmission(scopeA)) + val oldBody = wireBody("old", 7, "\"a\":${wireItem("1")}").encodeToByteArray() + val newBody = wireBody("new", 7, "\"a\":${wireItem("2")}").encodeToByteArray() + + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + core.admitWire(newToken, newBody, strongETag(newBody)).status, + ) + assertEquals( + RemoteConfigSnapshotTransitionStatus.Rejected, + core.admitWire(oldToken, oldBody, strongETag(oldBody)).status, + ) + assertEquals("new", core.lastFetchedSnapshot()?.releaseUid) + + val laterToken = requireNotNull(core.beginAdmission(scopeA)) + val laterBody = wireBody("later", 7, "\"a\":${wireItem("3")}").encodeToByteArray() + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + core.admitWire(laterToken, laterBody, strongETag(laterBody)).status, + ) + assertEquals("later", core.lastFetchedSnapshot()?.releaseUid) + + val orderedCore = RemoteConfigSnapshotCore(store, bundled) + orderedCore.setScope(scopeB) + val orderedOldToken = requireNotNull(orderedCore.beginAdmission(scopeB)) + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + orderedCore.admitWire( + orderedOldToken, + oldBody, + strongETag(oldBody), + ).status, + ) + val orderedNewToken = requireNotNull(orderedCore.beginAdmission(scopeB)) + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + orderedCore.admitWire( + orderedNewToken, + newBody, + strongETag(newBody), + ).status, + ) + assertEquals("new", orderedCore.lastFetchedSnapshot()?.releaseUid) + } + + @Test + fun `restart restores admission token high water mark`() { + core.setScope(scopeA) + val body = wireBody("wire", 7, "\"a\":${wireItem("1")}").encodeToByteArray() + val committedToken = requireNotNull(core.beginAdmission(scopeA)) + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + core.admitWire(committedToken, body, strongETag(body)).status, + ) + val committedOrdinal = requireNotNull(store.states.getValue(scopeA).candidate).admissionToken + + val restarted = RemoteConfigSnapshotCore(store, bundled) + restarted.setScope(scopeA) + val restartedToken = requireNotNull(restarted.beginAdmission(scopeA)) + val restartedBody = wireBody("wire-restarted", 7, "\"a\":${wireItem("2")}").encodeToByteArray() + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + restarted.admitWire(restartedToken, restartedBody, strongETag(restartedBody)).status, + ) + + assertTrue(requireNotNull(store.states.getValue(scopeA).candidate).admissionToken > committedOrdinal) + } + + @Test + fun `immediate same release keeps prior local generation as Previous decode tier`() { + core.setScope(scopeA) + val first = wireBody( + "first", + 7, + "\"a\":${wireItem("{\"shape\":1}", immediate = true)}", + ).encodeToByteArray() + val second = wireBody( + "second", + 7, + "\"a\":${wireItem("\"wrong-shape\"", immediate = true)}", + ).encodeToByteArray() + + assertEquals( + RemoteConfigSnapshotTransitionStatus.Activated, + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), + first, + strongETag(first), + ).status, + ) + assertEquals( + RemoteConfigSnapshotTransitionStatus.Activated, + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), + second, + strongETag(second), + ).status, + ) + + val resolved = core.currentSnapshot().value("a") { raw -> + raw.decodeToString().takeIf { it.startsWith("{") } + } + assertEquals(RemoteConfigSnapshotValueSource.Cache, resolved?.source) + assertEquals("{\"shape\":1}", resolved?.value) + val saved = store.states.getValue(scopeA) + assertEquals(7L, saved.active?.releaseNumber) + assertEquals(7L, saved.previous?.releaseNumber) + assertTrue(requireNotNull(saved.active).admissionToken > requireNotNull(saved.previous).admissionToken) + } + + private fun wireExpectation() = RemoteConfigSnapshotEnvelopeExpectation( + projectId = WIRE_PROJECT_ID, + environmentUid = "production", + ) + + /** + * Admits a body the way the coordinator does: the project id comes from the gateway session the + * response was served on, not from the admission claim. + */ + private fun RemoteConfigSnapshotCore.admitWire( + admissionToken: RemoteConfigSnapshotAdmissionToken, + body: ByteArray, + etag: String, + projectId: Long = WIRE_PROJECT_ID, + ) = admitCandidate(admissionToken, body, etag, projectId) + + private fun wireBody( + releaseUid: String, + releaseNumber: Long, + values: String, + contextFingerprint: String = "a".repeat(64), + environmentUid: String = "production", + projectId: Long = WIRE_PROJECT_ID, + ) = + "{\"schema_version\":1,\"project_id\":$projectId,\"environment_uid\":\"$environmentUid\"," + + "\"release_uid\":\"$releaseUid\",\"release_number\":$releaseNumber," + + "\"manifest_content_hash\":\"${hash(releaseNumber)}\",\"complete_key_set\":true," + + "\"context_fingerprint\":\"$contextFingerprint\",\"values\":{$values}}" + + private fun wireItem(raw: String, immediate: Boolean = false) = + "{\"raw\":$raw,\"variation_uid\":\"variation-wire\"," + + "\"apply_policy\":\"${if (immediate) "immediate" else "on_next_activate"}\"," + + "\"metadata\":null}" + + private fun strongETag(body: ByteArray): String = MessageDigest.getInstance("SHA-256") + .digest(body) + .joinToString(prefix = "\"", postfix = "\"", separator = "") { byte -> "%02x".format(byte) } + + private fun release( + uid: String, + number: Long, + values: Map, + immediateKey: String? = null, + ) = RemoteConfigSnapshotRelease( + releaseUid = uid, + releaseNumber = number, + manifestContentHash = hash(number), + entries = values.map { (key, value) -> + RemoteConfigSnapshotEntry.value( + key = key, + rawValue = value.encodeToByteArray(), + variationUid = "$uid-$key", + applyPolicy = if (key == immediateKey) { + RemoteConfigSnapshotApplyPolicy.Immediate + } else { + RemoteConfigSnapshotApplyPolicy.OnNextActivate + }, + metadata = "{\"release\":\"$uid\"}".encodeToByteArray(), + ) + }, + ) + + private fun hash(number: Long) = number.toString(16).padStart(64, '0') + + private class RecordingSnapshotStore : RemoteConfigSnapshotStore { + val states = mutableMapOf() + val savedStates = mutableListOf() + var failNextSave = false + var failNextLoads = 0 + var saveObserver: ((RemoteConfigSnapshotState) -> Unit)? = null + + override fun load(scope: RemoteConfigSnapshotScope): RemoteConfigSnapshotLoadResult { + if (failNextLoads > 0) { + failNextLoads-- + return RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Failed) + } + return states[scope]?.let { state -> + RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Found, state) + } ?: RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Missing) + } + + override fun save(scope: RemoteConfigSnapshotScope, state: RemoteConfigSnapshotState): Boolean { + if (failNextSave) { + failNextSave = false + return false + } + states[scope] = state + savedStates += state + saveObserver?.invoke(state) + return true + } + } + + private companion object { + const val WIRE_PROJECT_ID = 42L + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParserTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParserTest.kt new file mode 100644 index 000000000..b3a6cfbff --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParserTest.kt @@ -0,0 +1,277 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import java.security.MessageDigest + +internal class RemoteConfigSnapshotEnvelopeParserTest { + private val parser = RemoteConfigSnapshotEnvelopeParser() + private val expectation = RemoteConfigSnapshotEnvelopeExpectation( + projectId = 42, + environmentUid = "env-production", + ) + + @Test + fun `cross-language canonical body and strong ETag are admitted byte exactly`() { + val body = requireNotNull(javaClass.getResourceAsStream("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/remoteconfigv2/resolved-snapshot-v1.json")) + .bufferedReader(Charsets.UTF_8) + .use { it.readLine().encodeToByteArray() } + + val envelope = parser.parse(body, GOLDEN_ETAG, expectation) + + assertNotNull(envelope) + envelope ?: return + assertEquals(42, envelope.projectId) + assertEquals("env-production", envelope.environmentUid) + assertEquals("a".repeat(64), envelope.contextFingerprint) + assertEquals("release-uid", envelope.release.releaseUid) + assertEquals(7, envelope.release.releaseNumber) + assertEquals(GOLDEN_ETAG, envelope.etag) + assertEquals(GOLDEN_ETAG.removeSurrounding("\""), envelope.bodyDigest) + assertArrayEquals(body, envelope.canonicalBodyBytes) + assertArrayEquals("\"value\"".encodeToByteArray(), envelope.release.entry("alpha")?.rawValueBytes) + assertArrayEquals("null".encodeToByteArray(), envelope.release.entry("alpha")?.metadataBytes) + assertArrayEquals("{\"nested\":true}".encodeToByteArray(), envelope.release.entry("zeta")?.rawValueBytes) + assertArrayEquals( + "{\"resetNavigation\":true}".encodeToByteArray(), + envelope.release.entry("zeta")?.metadataBytes, + ) + assertEquals(RemoteConfigSnapshotApplyPolicy.Immediate, envelope.release.entry("zeta")?.applyPolicy) + } + + @Test + fun `strong ETag must be exact lowercase SHA256 over the exact body`() { + val body = validBody().encodeToByteArray() + val valid = strongETag(body) + + for (etag in listOf( + "W/$valid", + valid.uppercase(), + valid.removeSurrounding("\""), + " $valid", + "$valid ", + "$valid,$valid", + "\"${"a".repeat(63)}\"", + )) { + assertNull(etag, parser.parse(body, etag, expectation)) + } + assertNull(parser.parse(body + ' '.code.toByte(), valid, expectation)) + assertNotNull(parser.parse(body + ' '.code.toByte(), strongETag(body + ' '.code.toByte()), expectation)) + } + + @Test + fun `input byte and UTF8 limits are enforced before semantic admission`() { + assertNull(parser.parse(ByteArray(REMOTE_CONFIG_SNAPSHOT_ENVELOPE_MAX_BYTES + 1), "invalid", expectation)) + val invalidUtf8 = validBody().encodeToByteArray().clone().also { bytes -> + bytes[bytes.indexOf('v'.code.toByte())] = 0xff.toByte() + } + + assertNull(parser.parse(invalidUtf8, strongETag(invalidUtf8), expectation)) + } + + @Test + fun `expected project and environment are exact admission boundaries`() { + val body = validBody() + val mismatches = listOf( + expectation.copy(projectId = 43), + expectation.copy(environmentUid = "env-staging"), + ) + + mismatches.forEach { mismatch -> assertNull(parse(body, mismatch)) } + assertNull(parse(body.replace("\"project_id\":42", "\"project_id\":43"))) + assertNull(parse(body.replace("env-production", "env-staging"))) + } + + @Test + fun `the targeting context is shape checked and then carried through as an opaque tag`() { + // It hashes mutable targeting context (app/OS version, locale, purchases, properties), so + // it rotates legitimately and is never compared against a previous response's value. + val rotated = parse(withContextFingerprint("b".repeat(64))) + assertEquals("b".repeat(64), rotated?.contextFingerprint) + assertEquals("b".repeat(64), rotated?.release?.contextFingerprint) + + for (malformed in listOf( + "A".repeat(64), + "a".repeat(63), + "a".repeat(65), + "z".repeat(64), + "", + )) { + assertNull(malformed, parse(withContextFingerprint(malformed))) + } + assertNull(parse(validBody().replace("\"context_fingerprint\":\"${"a".repeat(64)}\",", ""))) + } + + @Test + fun `complete envelope rejects missing false unknown and duplicate members`() { + val body = validBody() + val malicious = listOf( + body.replace("\"complete_key_set\":true,", ""), + body.replace("\"complete_key_set\":true", "\"complete_key_set\":false"), + body.replace("\"schema_version\":1", "\"unknown\":0,\"schema_version\":1"), + body.replace("\"schema_version\":1", "\"schema_version\":1,\"schema_version\":1"), + body.replace("\"schema_version\":1", "\"schema_version\":1,\"schema_\\u0076ersion\":1"), + "$body{}", + ) + + malicious.forEach { json -> assertNull(json, parse(json)) } + } + + @Test + fun `value members and logical keys reject unknown duplicates omissions and overflow`() { + val body = validBody() + val malicious = listOf( + body.replace("\"raw\":true", "\"unknown\":null,\"raw\":true"), + body.replace("\"raw\":true", "\"raw\":false,\"raw\":true"), + body.replace("\"raw\":true,", ""), + body.replace("\"variation_uid\":\"variation\"", "\"variation_uid\":\"variation\",\"variation_uid\":\"other\""), + body.replace("\"only\":", "\"only\":${item()},\"\\u006fnly\":"), + body.replace("\"only\"", "\"${"k".repeat(257)}\""), + ) + + malicious.forEach { json -> assertNull(json, parse(json)) } + + val tooMany = (0..REMOTE_CONFIG_SNAPSHOT_MAX_KEYS_FOR_TEST).joinToString(",") { index -> + "\"key-$index\":${item()}" + } + assertNull(parse(validBody(values = tooMany))) + } + + @Test + fun `raw and metadata preserve whitespace and enforce portable JSON profile`() { + val exact = parse(validBody(values = "\"only\":${item(" { \"a\" : 1.0 } \n", " [ true ] ")}")) + assertArrayEquals(" { \"a\" : 1.0 } \n".encodeToByteArray(), exact?.release?.entry("only")?.rawValueBytes) + assertArrayEquals(" [ true ] ".encodeToByteArray(), exact?.release?.entry("only")?.metadataBytes) + + val invalidRaw = listOf( + "{\"duplicate\":1,\"duplicate\":2}", + "{\"a\":1,\"\\u0061\":2}", + "9007199254740992", + "-9007199254740992", + "1e400", + "\"\\uD800\"", + "[".repeat(REMOTE_CONFIG_JSON_MAX_DEPTH_FOR_TEST + 1) + + "null" + "]".repeat(REMOTE_CONFIG_JSON_MAX_DEPTH_FOR_TEST + 1), + ) + invalidRaw.forEach { raw -> assertNull(raw, parse(validBody(values = "\"only\":${item(raw)}"))) } + invalidRaw.forEach { metadata -> + assertNull(metadata, parse(validBody(values = "\"only\":${item(metadata = metadata)}"))) + } + } + + @Test + fun `per value metadata and aggregate release byte budgets fail closed`() { + val oversizedRaw = "\"${"r".repeat(64 * 1024 - 1)}\"" + val oversizedMetadata = "\"${"m".repeat(4 * 1024 - 1)}\"" + assertNull(parse(validBody(values = "\"only\":${item(raw = oversizedRaw)}"))) + assertNull(parse(validBody(values = "\"only\":${item(metadata = oversizedMetadata)}"))) + + val nearMaximumRaw = "\"${"v".repeat(64 * 1024 - 2)}\"" + val aggregateOverflow = (0 until 65).joinToString(",") { index -> + "\"key-$index\":${item(raw = nearMaximumRaw, variationUid = "variation-$index")}" + } + assertNull(parse(validBody(values = aggregateOverflow))) + } + + @Test + fun `recursive scanner stops at raw and metadata byte budgets`() { + val maliciousMembers = buildString(1024 * 1024) { + append('{') + repeat(80_000) { index -> + if (index > 0) append(',') + append("\"k") + append(index) + append("\":0") + } + append('}') + }.encodeToByteArray() + + val rawScan = scanPortableRemoteConfigJson(maliciousMembers, 64 * 1024) + val metadataScan = scanPortableRemoteConfigJson(maliciousMembers, 4 * 1024) + + assertEquals(false, rawScan.accepted) + assertEquals(false, metadataScan.accepted) + assertEquals(64 * 1024, rawScan.consumedBytes) + assertEquals(4 * 1024, metadataScan.consumedBytes) + } + + @Test + fun `raw and metadata spans accept exact byte limit and reject one trailing whitespace over`() { + val exactRaw = "true" + " ".repeat(64 * 1024 - "true".length) + val exactMetadata = "null" + " ".repeat(4 * 1024 - "null".length) + val exact = parse(validBody(values = "\"only\":${item(exactRaw, exactMetadata)}")) + + assertArrayEquals(exactRaw.encodeToByteArray(), exact?.release?.entry("only")?.rawValueBytes) + assertArrayEquals(exactMetadata.encodeToByteArray(), exact?.release?.entry("only")?.metadataBytes) + assertNull( + parse( + validBody( + values = "\"only\":${item(raw = "$exactRaw ", metadata = exactMetadata)}", + ), + ), + ) + assertNull( + parse( + validBody( + values = "\"only\":${item(raw = exactRaw, metadata = "$exactMetadata ")}", + ), + ), + ) + } + + @Test + fun `release identifiers numbers hashes and item enums match server bounds`() { + val body = validBody() + val malicious = listOf( + body.replace("\"release_uid\":\"release\"", "\"release_uid\":\"${"r".repeat(37)}\""), + body.replace("\"release_number\":7", "\"release_number\":0"), + body.replace("\"release_number\":7", "\"release_number\":9007199254740992"), + body.replace(HASH, HASH.uppercase()), + body.replace(HASH, "0".repeat(64)), + body.replace("\"variation_uid\":\"variation\"", "\"variation_uid\":\"${"v".repeat(37)}\""), + body.replace("on_next_activate", "unknown"), + ) + + malicious.forEach { json -> assertNull(json, parse(json)) } + } + + private fun parse( + json: String, + expected: RemoteConfigSnapshotEnvelopeExpectation = expectation, + ): RemoteConfigSnapshotEnvelope? { + val body = json.encodeToByteArray() + return parser.parse(body, strongETag(body), expected) + } + + private fun withContextFingerprint(fingerprint: String) = validBody().replace( + "\"context_fingerprint\":\"${"a".repeat(64)}\"", + "\"context_fingerprint\":\"$fingerprint\"", + ) + + private fun validBody(values: String = "\"only\":${item()}") = + "{\"schema_version\":1,\"project_id\":42,\"environment_uid\":\"env-production\"," + + "\"release_uid\":\"release\",\"release_number\":7,\"manifest_content_hash\":\"$HASH\"," + + "\"complete_key_set\":true,\"context_fingerprint\":\"${"a".repeat(64)}\"," + + "\"values\":{$values}}" + + private fun item( + raw: String = "true", + metadata: String = "null", + variationUid: String = "variation", + ) = "{\"raw\":$raw,\"variation_uid\":\"$variationUid\"," + + "\"apply_policy\":\"on_next_activate\",\"metadata\":$metadata}" + + private fun strongETag(body: ByteArray): String = MessageDigest.getInstance("SHA-256") + .digest(body) + .joinToString(prefix = "\"", postfix = "\"", separator = "") { byte -> "%02x".format(byte) } + + private companion object { + const val GOLDEN_ETAG = "\"d0c95fec2b0842242efdb0b8a034346538b7db97d6c5ff901501534ed940e3d3\"" + const val HASH = "05b3abf2579a5eb66403cd78be557fd860633a1fe2103c7642030defe32c657f" + const val REMOTE_CONFIG_SNAPSHOT_MAX_KEYS_FOR_TEST = 1_000 + const val REMOTE_CONFIG_JSON_MAX_DEPTH_FOR_TEST = 64 + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotTest.kt new file mode 100644 index 000000000..060d9aa28 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotTest.kt @@ -0,0 +1,271 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.internal.services.BundledRemoteConfigDefault +import com.qonversion.android.sdk.internal.services.BundledRemoteConfigDefaultsDocument +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.Assert.assertThrows + +internal class RemoteConfigSnapshotTest { + @Test + fun `typed resolution uses previous only when current raw cannot decode`() { + val current = release( + uid = "release-2", + number = 2, + values = mapOf( + "paywall" to "\"not-an-object\"", + "title" to "\"server-title\"", + ), + ) + val previous = release( + uid = "release-1", + number = 1, + values = mapOf( + "paywall" to "{\"title\":\"cached\"}", + "title" to "\"cached-title\"", + "previous-only" to "\"must-not-leak\"", + ), + ) + val bundled = release( + uid = "bundle", + number = 1, + values = mapOf( + "paywall" to "{\"title\":\"fallback\"}", + "bundle-only" to "true", + ), + ) + val snapshot = RemoteConfigSnapshot(current, previous, bundled) + + val typed = snapshot.value("paywall") { raw -> + raw.decodeToString().takeIf { it.startsWith("{") } + } + val raw = snapshot.rawValue("paywall") + + assertEquals(RemoteConfigSnapshotValueSource.Cache, typed?.source) + assertEquals("{\"title\":\"cached\"}", typed?.value) + assertEquals(RemoteConfigSnapshotValueSource.Server, raw?.source) + assertArrayEquals("\"not-an-object\"".encodeToByteArray(), raw?.value) + assertNull(snapshot.rawValue("previous-only")) + assertEquals(RemoteConfigSnapshotValueSource.Fallback, snapshot.rawValue("bundle-only")?.source) + } + + @Test + fun `missing and tombstone skip previous and resolve directly to bundle`() { + val current = RemoteConfigSnapshotRelease( + releaseUid = "release-2", + releaseNumber = 2, + manifestContentHash = "a".repeat(64), + entries = listOf(RemoteConfigSnapshotEntry.tombstone("removed")), + ) + val previous = release( + uid = "release-1", + number = 1, + values = mapOf("removed" to "\"private-old\"", "missing" to "\"private-old\""), + ) + val bundled = release( + uid = "bundle", + number = 1, + values = mapOf("removed" to "\"safe-default\"", "missing" to "\"safe-default\""), + ) + val snapshot = RemoteConfigSnapshot(current, previous, bundled) + + for (key in listOf("removed", "missing")) { + val raw = snapshot.rawValue(key) + val typed = snapshot.value(key) { it.decodeToString() } + + assertEquals(RemoteConfigSnapshotValueSource.Fallback, raw?.source) + assertArrayEquals("\"safe-default\"".encodeToByteArray(), raw?.value) + assertEquals(RemoteConfigSnapshotValueSource.Fallback, typed?.source) + assertEquals("\"safe-default\"", typed?.value) + } + } + + @Test + fun `held snapshot values metadata and updates are deeply immutable`() { + val raw = "{\"enabled\":true}".encodeToByteArray() + val metadata = "{\"reset\":true}".encodeToByteArray() + val entry = RemoteConfigSnapshotEntry.value( + key = "feature", + rawValue = raw, + variationUid = "variation", + applyPolicy = RemoteConfigSnapshotApplyPolicy.OnNextActivate, + metadata = metadata, + ) + val snapshot = RemoteConfigSnapshot( + primaryRelease = RemoteConfigSnapshotRelease("release", 1, "a".repeat(64), listOf(entry)), + previousRelease = null, + bundledRelease = null, + ) + val update = RemoteConfigSnapshotUpdate( + snapshot = snapshot, + changedKeys = setOf("feature"), + metadataByKey = mapOf("feature" to metadata), + ) + + raw.fill('x'.code.toByte()) + metadata.fill('x'.code.toByte()) + snapshot.rawValue("feature")?.value?.fill('y'.code.toByte()) + update.metadataForKey("feature")?.fill('y'.code.toByte()) + + assertArrayEquals("{\"enabled\":true}".encodeToByteArray(), snapshot.rawValue("feature")?.value) + assertArrayEquals("{\"reset\":true}".encodeToByteArray(), snapshot.metadataForKey("feature")) + assertArrayEquals("{\"reset\":true}".encodeToByteArray(), update.metadataForKey("feature")) + assertEquals(setOf("feature"), update.changedKeys) + } + + @Test + fun `bundled document integrates exact raw defaults without mutable aliases`() { + val raw = "{\"enabled\":true}".encodeToByteArray() + val document = BundledRemoteConfigDefaultsDocument( + projectId = 42, + environmentUid = "env-production", + releaseUid = "bundle-release", + releaseNumber = 7, + manifestContentHash = "a".repeat(64), + defaultsDigest = "b".repeat(64), + defaults = listOf( + BundledRemoteConfigDefault( + key = "feature", + variationUid = "bundle-variation", + valueBase64 = "unused-by-adapter", + rawJson = raw, + parsedValue = emptyMap(), + ), + ), + ) + + val scopedRelease = document.toScopedRemoteConfigSnapshotRelease("sdk-project-key") + val release = scopedRelease.release + raw.fill('x'.code.toByte()) + + assertEquals("sdk-project-key", scopedRelease.projectKey) + assertEquals("env-production", scopedRelease.environment) + assertEquals("bundle-release", release.releaseUid) + assertEquals(7, release.releaseNumber) + assertTrue(release.entries.keys.contains("feature")) + assertArrayEquals("{\"enabled\":true}".encodeToByteArray(), release.entry("feature")?.rawValueBytes) + } + + @Test + fun `release and entries enforce exact server resource and identifier bounds`() { + assertThrows(IllegalArgumentException::class.java) { + RemoteConfigSnapshotEntry.value( + key = "key", + rawValue = ("\"" + "x".repeat(64 * 1024) + "\"").encodeToByteArray(), + variationUid = "variation", + applyPolicy = RemoteConfigSnapshotApplyPolicy.OnNextActivate, + metadata = null, + ) + } + assertThrows(IllegalArgumentException::class.java) { + RemoteConfigSnapshotEntry.value( + key = "key", + rawValue = "true".encodeToByteArray(), + variationUid = "variation", + applyPolicy = RemoteConfigSnapshotApplyPolicy.OnNextActivate, + metadata = ("\"" + "x".repeat(4 * 1024) + "\"").encodeToByteArray(), + ) + } + val entry = RemoteConfigSnapshotEntry.value( + key = "key", + rawValue = "true".encodeToByteArray(), + variationUid = "variation", + applyPolicy = RemoteConfigSnapshotApplyPolicy.OnNextActivate, + metadata = null, + ) + for (invalidHash in listOf("hash", "A".repeat(64), "a".repeat(63))) { + assertThrows(IllegalArgumentException::class.java) { + RemoteConfigSnapshotRelease("release", 1, invalidHash, listOf(entry)) + } + } + assertThrows(IllegalArgumentException::class.java) { + RemoteConfigSnapshotRelease("r".repeat(37), 1, "a".repeat(64), listOf(entry)) + } + assertThrows(IllegalArgumentException::class.java) { + RemoteConfigSnapshotRelease( + "release", + 1, + "a".repeat(64), + List(1_001) { index -> RemoteConfigSnapshotEntry.tombstone("key-$index") }, + ) + } + + val nearMaximumRaw = ("\"" + "x".repeat(64 * 1024 - 2) + "\"").encodeToByteArray() + assertThrows(IllegalArgumentException::class.java) { + RemoteConfigSnapshotRelease( + "release", + 1, + "a".repeat(64), + List(65) { index -> + RemoteConfigSnapshotEntry.value( + key = "key-$index", + rawValue = nearMaximumRaw, + variationUid = "variation-$index", + applyPolicy = RemoteConfigSnapshotApplyPolicy.OnNextActivate, + metadata = null, + ) + }, + ) + } + } + + @Test + fun `scope enforces exact identity boundary limits`() { + val valid = RemoteConfigSnapshotScope( + projectKey = "é".repeat(128), + environment = "😀".repeat(36), + canonicalUserId = "u".repeat(256), + ) + + assertEquals(256, valid.projectKey.toByteArray().size) + assertEquals(36, valid.environment.codePointCount(0, valid.environment.length)) + assertEquals(256, valid.canonicalUserId.toByteArray().size) + + for (invalid in listOf( + Triple("", "production", "user"), + Triple("p".repeat(257), "production", "user"), + Triple("project", "", "user"), + Triple("project", "e".repeat(37), "user"), + Triple("project", "production", "u".repeat(257)), + Triple("project", "production", "\uD800"), + )) { + assertThrows(IllegalArgumentException::class.java) { + RemoteConfigSnapshotScope(invalid.first, invalid.second, invalid.third) + } + } + } + + @Test + fun `scope storage identity remains unambiguous when values contain separators`() { + val first = RemoteConfigSnapshotScope("a\u0000b", "c", "d") + val second = RemoteConfigSnapshotScope("a", "b", "c\u0000d") + + assertTrue( + com.qonversion.android.sdk.internal.storage.remoteConfigSnapshotStorageKey(first) != + com.qonversion.android.sdk.internal.storage.remoteConfigSnapshotStorageKey(second), + ) + } + + private fun release( + uid: String, + number: Long, + values: Map, + policy: RemoteConfigSnapshotApplyPolicy = RemoteConfigSnapshotApplyPolicy.OnNextActivate, + ) = RemoteConfigSnapshotRelease( + releaseUid = uid, + releaseNumber = number, + manifestContentHash = "a".repeat(64), + entries = values.map { (key, value) -> + RemoteConfigSnapshotEntry.value( + key = key, + rawValue = value.encodeToByteArray(), + variationUid = "$uid-$key", + applyPolicy = policy, + metadata = null, + ) + }, + ) +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigTelemetryFactoryDormancyTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigTelemetryFactoryDormancyTest.kt new file mode 100644 index 000000000..5de4d489c --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigTelemetryFactoryDormancyTest.kt @@ -0,0 +1,138 @@ +@file:OptIn(ExperimentalQonversionApi::class) + +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi +import com.qonversion.android.sdk.dto.QEnvironment +import com.qonversion.android.sdk.dto.QLaunchMode +import com.qonversion.android.sdk.dto.entitlements.QEntitlementsCacheLifetime +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchResult +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchStatus +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigV2Config +import com.qonversion.android.sdk.internal.InternalConfig +import com.qonversion.android.sdk.internal.dto.config.CacheConfig +import com.qonversion.android.sdk.internal.dto.config.PrimaryConfig +import com.qonversion.android.sdk.internal.storage.Cache +import com.qonversion.android.sdk.listeners.QonversionRemoteConfigFetchCallback +import com.squareup.moshi.JsonAdapter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import java.util.concurrent.atomic.AtomicReference + +private const val TELEMETRY_KEY_PREFIX = "qonversion_remote_config_v2_telemetry_" + +/** + * Telemetry is built by [RemoteConfigV2Factory] and therefore inherits the feature's single switch. + * + * Without a [QRemoteConfigV2Config] the factory builds no [RemoteConfigV2Manager] at all, and the + * telemetry sender is reachable only through one — so "no manager" is not a proxy for dormancy, it + * is dormancy. The behavioural assertions below pin the consequence: nothing is read from or + * written to storage, and a read still answers. + */ +@RunWith(RobolectricTestRunner::class) +internal class RemoteConfigTelemetryFactoryDormancyTest { + + @Test + fun `an unconfigured SDK builds no telemetry machinery at all`() { + val cache = RecordingCache() + + val configs = RemoteConfigV2Factory.create( + application = RuntimeEnvironment.getApplication(), + internalConfig = internalConfig(remoteConfigV2Config = null), + cache = cache, + logger = SilentLogger(), + ) + + // No manager means no sender, no durable buffer, no timer and no queue. + assertNull("a dormant SDK built the Remote Config v2 chain", configs.manager) + assertEquals(QRemoteConfigFetchStatus.NotConfigured, fetchBlocking(configs).status) + assertTrue("a dormant SDK read a value", configs.current.contextKeys.isEmpty()) + assertTrue(cache.touchedKeys.none { it.startsWith(TELEMETRY_KEY_PREFIX) }) + assertTrue(cache.durableWrites.isEmpty()) + } + + @Test + fun `a configured SDK builds the chain but stays silent until an identity binds it`() { + val cache = RecordingCache() + + val configs = RemoteConfigV2Factory.create( + application = RuntimeEnvironment.getApplication(), + internalConfig = internalConfig( + remoteConfigV2Config = QRemoteConfigV2Config( + baseUrl = "https://rc.example.invalid/", + environmentUid = "production", + ), + ), + cache = cache, + logger = SilentLogger(), + ) + + // Without this the dormancy assertion above would also pass if the factory had stopped + // building the chain entirely. + assertNotNull(configs.manager) + // Constructing the chain touches no telemetry storage: the buffer is only read when an + // identity binds the sender. + assertTrue(cache.touchedKeys.none { it.startsWith(TELEMETRY_KEY_PREFIX) }) + assertTrue(cache.durableWrites.none { it.startsWith(TELEMETRY_KEY_PREFIX) }) + } + + private fun fetchBlocking(configs: QRemoteConfigSnapshotsImpl): QRemoteConfigFetchResult { + val result = AtomicReference() + configs.fetch( + object : QonversionRemoteConfigFetchCallback { + override fun onResult(result1: QRemoteConfigFetchResult) = result.set(result1) + }, + ) + shadowOf(android.os.Looper.getMainLooper()).idle() + return requireNotNull(result.get()) + } + + private fun internalConfig(remoteConfigV2Config: QRemoteConfigV2Config?) = InternalConfig( + primaryConfig = PrimaryConfig( + projectKey = "project-key", + launchMode = QLaunchMode.SubscriptionManagement, + environment = QEnvironment.Sandbox, + ), + cacheConfig = CacheConfig( + entitlementsCacheLifetime = QEntitlementsCacheLifetime.Month, + fallbackFileIdentifier = null, + ), + remoteConfigV2Config = remoteConfigV2Config, + ) + + private class RecordingCache : Cache { + val touchedKeys = mutableListOf() + val durableWrites = mutableListOf() + + override fun putInt(key: String, value: Int) = Unit + override fun getInt(key: String, defValue: Int): Int = defValue + override fun getBool(key: String, defValue: Boolean): Boolean = defValue + override fun putBool(key: String, value: Boolean) = Unit + override fun putFloat(key: String, value: Float) = Unit + override fun getFloat(key: String, defValue: Float): Float = defValue + override fun putLong(key: String, value: Long) = Unit + override fun getLong(key: String, defValue: Long): Long = defValue + override fun putString(key: String, value: String?) { touchedKeys += key } + override fun remove(key: String) { touchedKeys += key } + + override fun getString(key: String, defValue: String?): String? { + touchedKeys += key + return defValue + } + + override fun updateStringsDurably(values: Map, removedKeys: Set): Boolean { + durableWrites += values.keys + removedKeys + return true + } + + override fun putObject(key: String, value: T, adapter: JsonAdapter) = Unit + override fun getObject(key: String, adapter: JsonAdapter): T? = null + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigTelemetrySenderTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigTelemetrySenderTest.kt new file mode 100644 index 000000000..73c70db16 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigTelemetrySenderTest.kt @@ -0,0 +1,1145 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import com.squareup.moshi.Moshi +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.ArrayDeque +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executor +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.TimeUnit + +private const val POLL_INTERVAL_MILLIS = 10L +private const val PROJECT_TOKEN = "project-token" +private const val SESSION_TOKEN = "qrcs1.session" +private const val SEEDED_SESSION_TOKEN = "qrcs1.seeded-session" +// Long enough to outlive the clock jumps the staleness tests perform, so an expired session can +// never be mistaken for the pruning behaviour under test. +private const val SESSION_LIFETIME_MILLIS = 90L * 24 * 60 * 60 * 1_000 +private const val OCCURRED_AT_SECONDS = 1_700_000_000L +private const val LATER_OCCURRED_AT_SECONDS = 1_700_000_900L +private const val RELEASE_7 = 7L +private const val RELEASE_9 = 9L +private const val HTTP_BAD_REQUEST = 400 +private const val RETRY_SCRIPT_SIZE = 8 +private const val TEST_FLUSH_THRESHOLD = 3 +private const val TEST_MAX_BATCH_EVENTS = 4 + +/** + * Client telemetry, end to end over a real [MockWebServer]: the shipped + * [RemoteConfigGatewayTransport] under the shipped [RemoteConfigTelemetrySender]. + * + * Only four things are doubles, each for determinism: the durable store is in memory, the retry and + * tick scheduler is manual, the jitter source is fixed, and the worker executor is manual — the + * last one is not a convenience but the point of several tests, because it is what makes "the read + * path only enqueues" observable. + */ +@Suppress("LargeClass") +internal class RemoteConfigTelemetrySenderTest { + private lateinit var server: MockWebServer + /** + * One HTTP client per sender, deliberately. + * + * A "process restart" test binds a second sender while the first one's batch is still parked + * on the wire; sharing a pooled connection would make the second request queue behind the + * first one's unwritten response instead of reaching the gateway. + */ + private val clients = Collections.synchronizedList(mutableListOf()) + private lateinit var gateway: ScriptedGateway + private lateinit var store: InMemoryTelemetryStore + private lateinit var sessionStore: InMemorySessionStore + private lateinit var scheduler: ManualScheduler + private lateinit var worker: ManualExecutor + private var identityScope: RemoteConfigSnapshotScope? = SCOPE_A + private val clock = MutableTelemetryClock(OCCURRED_AT_SECONDS * 1_000) + + @Before + fun setUp() { + server = MockWebServer() + gateway = ScriptedGateway() + server.dispatcher = gateway + server.start() + store = InMemoryTelemetryStore() + scheduler = ManualScheduler() + worker = ManualExecutor() + identityScope = SCOPE_A + // Telemetry can only follow a read, so the realistic starting state is a session this + // installation already holds — which is also what licenses the single re-bootstrap on 401. + sessionStore = InMemorySessionStore() + listOf(SCOPE_A, SCOPE_B).forEach { scope -> + sessionStore.save( + RemoteConfigSessionKey(scope, scope.canonicalUserId), + RemoteConfigGatewaySession( + token = SEEDED_SESSION_TOKEN, + projectId = RC_PROJECT_ID, + environment = "prod", + expiresAtMillis = clock.now + SESSION_LIFETIME_MILLIS, + ), + ) + } + } + + @After + fun tearDown() { + // A test that models a crash leaves a batch parked forever; the gateway cannot shut down + // while a dispatcher thread is still holding one. + gateway.releaseAll() + server.shutdown() + clients.forEach { open -> + open.dispatcher().executorService().shutdownNow() + open.connectionPool().evictAll() + } + } + + @Test + fun `a telemetry batch matches the gateway contract exactly`() { + val sender = sender() + sender.bind(SCOPE_A) + + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + flush(sender) + awaitBatches(1) + + val batch = gateway.batches.single() + assertEquals("POST", batch.method) + assertEquals("/$REMOTE_CONFIG_TELEMETRY_PATH", batch.path) + assertEquals("Bearer $PROJECT_TOKEN", batch.authorization) + assertEquals("application/json; charset=utf-8", batch.contentType) + assertEquals(SEEDED_SESSION_TOKEN, batch.sessionHeader) + assertEquals( + "{\"events\":[{\"kind\":\"decode_failure\",\"logical_key\":\"paywall_prices\"," + + "\"release_number\":$RELEASE_7,\"count\":1,\"last_occurred_at\":$OCCURRED_AT_SECONDS}]}", + batch.body, + ) + assertEquals(0, sender.droppedEventCount) + } + + @Test + fun `repeated occurrences of the same key coalesce into one counted event`() { + val sender = sender() + sender.bind(SCOPE_A) + + repeat(3) { sender.recordDecodeFailure("paywall_prices", RELEASE_7) } + clock.now = LATER_OCCURRED_AT_SECONDS * 1_000 + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + assertEquals(1, sender.pendingEntryCount) + flush(sender) + awaitBatches(1) + + // One event, four occurrences, stamped with the LAST one. + assertEquals( + "{\"events\":[{\"kind\":\"decode_failure\",\"logical_key\":\"paywall_prices\"," + + "\"release_number\":$RELEASE_7,\"count\":4," + + "\"last_occurred_at\":$LATER_OCCURRED_AT_SECONDS}]}", + gateway.batches.single().body, + ) + } + + @Test + fun `the same key across a release rollover stays exactly one event`() { + val sender = sender() + sender.bind(SCOPE_A) + + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + clock.now = LATER_OCCURRED_AT_SECONDS * 1_000 + // A new release rolled out between two flushes and still fails to decode. + sender.recordDecodeFailure("paywall_prices", RELEASE_9) + + // The gateway refuses a batch carrying two events for one (kind, logical_key), so a + // rollover must never be able to produce that pair. + assertEquals(1, sender.pendingEntryCount) + flush(sender) + awaitBatches(1) + // Counts add, and the NEWEST release wins: that is the one the dashboard has to act on. + assertEquals( + "{\"events\":[{\"kind\":\"decode_failure\",\"logical_key\":\"paywall_prices\"," + + "\"release_number\":$RELEASE_9,\"count\":3," + + "\"last_occurred_at\":$LATER_OCCURRED_AT_SECONDS}]}", + gateway.batches.single().body, + ) + } + + @Test + fun `different keys stay different events`() { + val sender = sender() + sender.bind(SCOPE_A) + + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + sender.recordDecodeFailure("onboarding", RELEASE_7) + + assertEquals(2, sender.pendingEntryCount) + flush(sender) + awaitBatches(1) + assertEquals(2, gateway.batches.single().eventCount()) + } + + @Test + fun `a guard event never carries a logical key`() { + val sender = sender() + sender.bind(SCOPE_A) + + sender.record(RemoteConfigReadGuardEvent.ReadBeforeActivate) + flush(sender) + awaitBatches(1) + + // The gateway rejects the whole batch when a non-decode kind names a key, so the field must + // be absent rather than empty. + val body = gateway.batches.single().body + assertFalse(body.contains("logical_key")) + assertEquals( + "{\"events\":[{\"kind\":\"read_before_activate\",\"release_number\":0," + + "\"count\":1,\"last_occurred_at\":$OCCURRED_AT_SECONDS}]}", + body, + ) + } + + @Test + fun `every read guard event maps onto the closed wire enum`() { + val sender = sender(maxBatchEvents = REMOTE_CONFIG_TELEMETRY_MAX_BATCH_EVENTS) + sender.bind(SCOPE_A) + + RemoteConfigReadGuardEvent.values().forEach(sender::record) + flush(sender) + awaitBatches(1) + + // Six guard events are five kinds because PreloadNotReady is DROPPED, not folded: the + // contract forbids reporting "nothing persisted yet" as a preload failure. + assertEquals( + listOf( + "read_before_activate", + "implicit_activation", + "preload_failed", + "preload_corrupt", + "activation_persistence_failed", + ), + gateway.batches.single().kinds(), + ) + } + + @Test + fun `the ordinary first-launch preload state is not reported at all`() { + val sender = sender() + sender.bind(SCOPE_A) + + sender.record(RemoteConfigReadGuardEvent.PreloadNotReady) + + // "Nothing persisted yet" is what EVERY fresh install looks like. Reporting it as + // preload_failed would make the dashboard's alarm metric fire for every new user. + assertEquals(0, sender.pendingEntryCount) + flush(sender) + assertEquals(emptyList(), gateway.batches) + // Not a drop either: it was never an event. + assertEquals(0, sender.droppedEventCount) + } + + @Test + fun `a genuine preload failure is still reported`() { + val sender = sender() + sender.bind(SCOPE_A) + + sender.record(RemoteConfigReadGuardEvent.PreloadFailed) + flush(sender) + awaitBatches(1) + + assertEquals(listOf("preload_failed"), gateway.batches.single().kinds()) + } + + @Test + fun `a stale event is pruned instead of poisoning the batch`() { + val sender = sender() + sender.bind(SCOPE_A) + sender.recordDecodeFailure("ancient", RELEASE_7) + + // The device came back a month later, and a healthy event happened on the way. + clock.now = (OCCURRED_AT_SECONDS + REMOTE_CONFIG_TELEMETRY_MAX_AGE_SECONDS + 1) * 1_000 + sender.recordDecodeFailure("fresh", RELEASE_7) + flush(sender) + awaitBatches(1) + + // A 400 is terminal for the WHOLE batch, so the out-of-window event must never travel. + assertEquals(listOf("fresh"), gateway.batches.single().logicalKeys()) + assertEquals(1, sender.droppedEventCount) + } + + @Test + fun `a future-skewed event is pruned instead of poisoning the batch`() { + val sender = sender() + sender.bind(SCOPE_A) + // A device whose clock is running ahead of the server's window. + clock.now = (OCCURRED_AT_SECONDS + REMOTE_CONFIG_TELEMETRY_MAX_SKEW_SECONDS + 60) * 1_000 + sender.recordDecodeFailure("from_the_future", RELEASE_7) + + clock.now = OCCURRED_AT_SECONDS * 1_000 + sender.recordDecodeFailure("fresh", RELEASE_7) + flush(sender) + awaitBatches(1) + + assertEquals(listOf("fresh"), gateway.batches.single().logicalKeys()) + assertEquals(1, sender.droppedEventCount) + } + + @Test + fun `a flush never bootstraps a session and keeps its events buffered`() { + sessionStore.clear(RemoteConfigSessionKey(SCOPE_A, SCOPE_A.canonicalUserId)) + val sender = sender() + sender.bind(SCOPE_A) + + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + flush(sender) + + // Session establishment belongs to the config read path: a diagnostic signal must not be + // the reason an installation contacts the gateway at all. + assertTrue(gateway.sessions.isEmpty()) + assertEquals(emptyList(), gateway.batches) + // Not an attempt: no retry budget spent, nothing dropped, everything still owed. + assertEquals(1, sender.pendingEntryCount) + assertEquals(0, sender.droppedEventCount) + assertEquals(emptyList(), retryDelays()) + } + + @Test + fun `a counter-only bump never reaches for storage`() { + val sender = sender() + sender.bind(SCOPE_A) + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + worker.runAll() + assertEquals(1, store.load(SCOPE_A).single().count) + + // A disk write per config read is not acceptable on a path the app can drive; losing a few + // increments to a crash is a rounding error in an aggregate metric. + repeat(10) { sender.recordDecodeFailure("paywall_prices", RELEASE_7) } + worker.runAll() + assertEquals(1, store.load(SCOPE_A).single().count) + + // The increments were not lost, they were simply never written: the in-memory bucket still + // reports all eleven occurrences. + flush(sender) + awaitBatches(1) + assertEquals(11, gateway.batches.single().count()) + } + + @Test + fun `a counter-only bump at or above the flush threshold still never reaches for storage`() { + // The regression this pins: with the threshold merely TESTED rather than latched, every + // bump past the tenth bucket scheduled a drain — a synchronous preferences commit on the + // single worker the snapshot preloader and the manager also run on. Telemetry would starve + // the config path through its own queue. + // Parked so the batch stays in flight and the buffer stays exactly at the threshold: this + // test is about what a counter bump costs, not about what a settled batch costs. + gateway.parkBatchesOn(CountDownLatch(1)) + val sender = sender() + sender.bind(SCOPE_A) + repeat(TEST_FLUSH_THRESHOLD) { index -> sender.recordDecodeFailure("key-$index", RELEASE_7) } + worker.runAll() + awaitBatches(1) + val writesAtThreshold = store.writeCount + + repeat(20) { sender.recordDecodeFailure("key-0", RELEASE_7) } + worker.runAll() + + assertEquals(writesAtThreshold, store.writeCount) + assertEquals(TEST_FLUSH_THRESHOLD, sender.pendingEntryCount) + } + + @Test + fun `an unchanged buffer is never rewritten`() { + gateway.parkBatchesOn(CountDownLatch(1)) + val sender = sender() + sender.bind(SCOPE_A) + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + worker.runAll() + val writesAfterFirstEntry = store.writeCount + + // Repeated drains of a buffer that did not change shape must cost nothing. + repeat(5) { + sender.onSuccessfulFetch() + worker.runAll() + } + + assertEquals(writesAfterFirstEntry, store.writeCount) + } + + @Test + fun `a resumed buffer is not rewritten by the bind that resumed it`() { + val sender = sender() + sender.bind(SCOPE_A) + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + worker.runAll() + gateway.parkBatchesOn(CountDownLatch(1)) + + // A second process over the same durable state: it already holds exactly what was loaded. + val restarted = sender() + restarted.bind(SCOPE_A) + val writesAfterBind = store.writeCount + worker.runAll() + + assertEquals(writesAfterBind, store.writeCount) + } + + @Test + fun `a record during a slow bind does not block on storage`() { + val loading = CountDownLatch(1) + val loadStarted = CountDownLatch(1) + store.blockLoadsOn(loadStarted, loading) + val sender = sender() + val binder = Thread { sender.bind(SCOPE_A) }.apply { start() } + assertTrue("the bind never reached storage", loadStarted.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + + // The read path may not queue behind an identity change reading and parsing up to 64 KiB. + val recorded = Thread { sender.recordDecodeFailure("paywall_prices", RELEASE_7) }.apply { start() } + recorded.join(TimeUnit.SECONDS.toMillis(RC_AWAIT_SECONDS)) + val blocked = recorded.isAlive + loading.countDown() + binder.join(TimeUnit.SECONDS.toMillis(RC_AWAIT_SECONDS)) + recorded.join(TimeUnit.SECONDS.toMillis(RC_AWAIT_SECONDS)) + + assertFalse("a config read blocked on the telemetry store", blocked) + } + + @Test + fun `a fetch policy persistence failure is reported as a persistence failure`() { + val sender = sender() + sender.bind(SCOPE_A) + + sender.recordPolicyPersistenceFailure() + flush(sender) + awaitBatches(1) + + assertEquals(listOf("activation_persistence_failed"), gateway.batches.single().kinds()) + } + + @Test + fun `a guard event produced before the first bind is delivered after it`() { + val sender = sender() + + // The read guard reports each of its events ONCE per process. A read_before_activate that + // happens between SDK construction and the first identify has no second chance, so dropping + // it here would systematically under-report the metric the panel exists for. + sender.record(RemoteConfigReadGuardEvent.ReadBeforeActivate) + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + flush(sender) + assertEquals(emptyList(), gateway.batches) + assertEquals(0, sender.pendingEntryCount) + + sender.bind(SCOPE_A) + worker.runAll() + awaitBatches(1) + + assertEquals(listOf("read_before_activate", "decode_failure"), gateway.batches.single().kinds()) + assertEquals(0, sender.droppedEventCount) + } + + @Test + fun `the pre-bind buffer is bounded and counts what it drops`() { + val sender = sender(maxPreBindEvents = 4) + + repeat(10) { index -> sender.recordDecodeFailure("key-$index", RELEASE_7) } + sender.bind(SCOPE_A) + worker.runAll() + awaitBatches(1) + + // Drop-oldest: the four newest survive, and the six lost occurrences are MEASURED rather + // than silently discarded. + assertEquals(listOf("key-6", "key-7", "key-8", "key-9"), gateway.batches.single().logicalKeys()) + assertEquals(6, sender.droppedEventCount) + } + + @Test + fun `an unbindable sender never grows past the pre-bind bound`() { + val sender = sender(maxPreBindEvents = 4) + + repeat(1_000) { index -> sender.recordDecodeFailure("key-$index", RELEASE_7) } + + assertEquals(0, sender.pendingEntryCount) + assertEquals(996, sender.droppedEventCount) + } + + @Test + fun `pruning a stale buffer clears the durable record instead of leaving it immortal`() { + val sender = sender() + sender.bind(SCOPE_A) + sender.recordDecodeFailure("ancient", RELEASE_7) + worker.runAll() + assertEquals(1, store.load(SCOPE_A).size) + + // A month later, on a new process over the same durable state. + clock.now = (OCCURRED_AT_SECONDS + REMOTE_CONFIG_TELEMETRY_MAX_AGE_SECONDS + 1) * 1_000 + val restarted = sender() + restarted.bind(SCOPE_A) + worker.runAll() + + // Without a durable prune the record survives every restart and re-inflates the counter + // once per process, forever. + assertTrue("the all-stale record outlived its prune", store.load(SCOPE_A).isEmpty()) + assertEquals(1, restarted.droppedEventCount) + assertEquals(emptyList(), gateway.batches) + + val secondRestart = sender() + secondRestart.bind(SCOPE_A) + worker.runAll() + assertEquals(0, secondRestart.droppedEventCount) + } + + @Test + fun `an untrusted clock suspends pruning and flushing instead of discarding the buffer`() { + val sender = sender() + sender.bind(SCOPE_A) + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + worker.runAll() + + // A device whose RTC has not been set yet. Believing it would make every buffered event + // look future-skewed and throw the whole buffer away. + clock.now = 0 + flush(sender) + + assertEquals(emptyList(), gateway.batches) + assertEquals(1, sender.pendingEntryCount) + assertEquals(0, sender.droppedEventCount) + assertEquals(1, store.load(SCOPE_A).size) + + // Once the clock is real again the buffer is delivered, not mourned. + clock.now = OCCURRED_AT_SECONDS * 1_000 + flush(sender) + awaitBatches(1) + } + + @Test + fun `a permanently refused identity is never sent again`() { + gateway.scriptTelemetry(HTTP_BAD_REQUEST) + val sender = sender() + sender.bind(SCOPE_A) + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + flush(sender) + awaitBatches(1) + awaitDropped(sender, 1) + + // A 400 is deterministic for a given (kind, logical_key). Re-sending it every tick would be + // an infinite request loop that also poisons every healthy event travelling with it. + repeat(5) { sender.recordDecodeFailure("paywall_prices", RELEASE_7) } + flush(sender) + scheduler.runAll() + worker.runAll() + + assertEquals(1, gateway.batches.size) + assertEquals(0, sender.pendingEntryCount) + assertEquals(6, sender.droppedEventCount) + } + + @Test + fun `occurrences accrued while a refused batch was on the wire do not survive it`() { + val onTheWire = CountDownLatch(1) + gateway.parkBatchesOn(onTheWire) + gateway.scriptTelemetry(HTTP_BAD_REQUEST) + val sender = sender() + sender.bind(SCOPE_A) + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + flush(sender) + awaitBatches(1) + + repeat(2) { sender.recordDecodeFailure("paywall_prices", RELEASE_7) } + gateway.parkBatchesOn(null) + onTheWire.countDown() + + awaitDropped(sender, 3) + // The whole bucket goes, not just the reported count: a residue would re-flush on the very + // next tick and be refused again. + awaitBuffer { it.isEmpty() } + scheduler.runAll() + worker.runAll() + assertEquals(1, gateway.batches.size) + } + + @Test + fun `a key that is not valid UTF-8 is refused locally`() { + val sender = sender() + sender.bind(SCOPE_A) + + // An unpaired high surrogate: `toByteArray` would transcode it to '?' silently, so a + // byte-level check alone would send a key the app never read. + sender.recordDecodeFailure("paywall\uD83Dprices", RELEASE_7) + // 150 emoji are 150 UTF-16 units but 600 UTF-8 bytes: the server bounds the BYTES. + sender.recordDecodeFailure("😀".repeat(150), RELEASE_7) + + assertEquals(0, sender.pendingEntryCount) + assertEquals(2, sender.droppedEventCount) + } + + @Test + fun `a key at the byte budget is still accepted`() { + val sender = sender() + sender.bind(SCOPE_A) + + // 50 four-byte emoji are exactly 200 bytes: the bound is inclusive, on bytes. + sender.recordDecodeFailure("😀".repeat(50), RELEASE_7) + + assertEquals(1, sender.pendingEntryCount) + assertEquals(0, sender.droppedEventCount) + } + + @Test + fun `a batch that crashed mid-flight is redelivered by the next process`() { + // Only the FIRST batch is parked, and it is never released: that is what a process death + // between "the POST left" and "the gateway answered" actually looks like. + gateway.parkBatchesOn(CountDownLatch(1), limit = 1) + val crashed = sender() + crashed.bind(SCOPE_A) + crashed.recordDecodeFailure("paywall_prices", RELEASE_7) + flush(crashed) + awaitBatches(1) + + // It was persisted BEFORE it was dispatched — an in-flight batch is only removed from the + // buffer when the gateway answers — so the durable record still holds it. + assertEquals(1, store.load(SCOPE_A).size) + + worker = ManualExecutor() + scheduler = ManualScheduler() + val restarted = sender() + restarted.bind(SCOPE_A) + worker.runAll() + + awaitBatches(2) + // Redelivered whole: at-least-once is the pinned delivery semantic, loss is not. + assertEquals(1, gateway.batches[1].count()) + awaitBuffer { it.isEmpty() } + } + + @Test + fun `reaching the distinct entry threshold flushes without waiting for a tick`() { + val sender = sender() + sender.bind(SCOPE_A) + + repeat(TEST_FLUSH_THRESHOLD - 1) { index -> sender.recordDecodeFailure("key-$index", RELEASE_7) } + worker.runAll() + assertEquals(emptyList(), gateway.batches) + + sender.recordDecodeFailure("key-last", RELEASE_7) + worker.runAll() + + awaitBatches(1) + assertEquals(TEST_FLUSH_THRESHOLD, gateway.batches.single().eventCount()) + } + + @Test + fun `a buffer below the threshold is delivered by the periodic tick`() { + val sender = sender() + sender.bind(SCOPE_A) + + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + worker.runAll() + assertEquals(emptyList(), gateway.batches) + + // The tick is armed by the first buffered event, at the contract's 30 s. + assertEquals(listOf(REMOTE_CONFIG_TELEMETRY_TICK_MILLIS), scheduler.requestedDelays) + scheduler.runAll() + worker.runAll() + + awaitBatches(1) + } + + @Test + fun `an idle sender arms no timer at all`() { + val sender = sender() + + sender.bind(SCOPE_A) + + // A dormant integration must not wake a thread every 30 seconds for an empty batch. + assertEquals(emptyList(), scheduler.requestedDelays) + assertEquals(0, scheduler.pendingCount()) + } + + @Test + fun `a fetch the gateway answered flushes opportunistically`() { + val sender = sender() + sender.bind(SCOPE_A) + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + worker.runAll() + assertEquals(emptyList(), gateway.batches) + + sender.onSuccessfulFetch() + worker.runAll() + + awaitBatches(1) + } + + @Test + fun `distinct entries beyond the bound are dropped instead of growing the map`() { + val sender = sender(maxEntries = REMOTE_CONFIG_TELEMETRY_MAX_ENTRIES) + sender.bind(SCOPE_A) + + repeat(REMOTE_CONFIG_TELEMETRY_MAX_ENTRIES + 5) { index -> + sender.recordDecodeFailure("key-$index", RELEASE_7) + } + + assertEquals(REMOTE_CONFIG_TELEMETRY_MAX_ENTRIES, sender.pendingEntryCount) + assertEquals(5, sender.droppedEventCount) + // An already-known key still counts up: the bound is on distinct entries, not occurrences. + sender.recordDecodeFailure("key-0", RELEASE_7) + assertEquals(REMOTE_CONFIG_TELEMETRY_MAX_ENTRIES, sender.pendingEntryCount) + assertEquals(5, sender.droppedEventCount) + } + + @Test + fun `an event the gateway could never accept is dropped at the source`() { + val sender = sender() + sender.bind(SCOPE_A) + + // Over the 200-byte logical key budget: queueing it would cost every other event in the + // same POST a terminal 400. + sender.recordDecodeFailure("k".repeat(REMOTE_CONFIG_TELEMETRY_LOGICAL_KEY_MAX_BYTES + 1), RELEASE_7) + + assertEquals(0, sender.pendingEntryCount) + assertEquals(1, sender.droppedEventCount) + } + + @Test + fun `a batch is capped and the remainder follows immediately`() { + val sender = sender() + sender.bind(SCOPE_A) + + repeat(TEST_MAX_BATCH_EVENTS + 2) { index -> sender.recordDecodeFailure("key-$index", RELEASE_7) } + flush(sender) + + awaitBatches(2) + assertEquals(TEST_MAX_BATCH_EVENTS, gateway.batches[0].eventCount()) + assertEquals(2, gateway.batches[1].eventCount()) + awaitBuffer { it.isEmpty() } + } + + @Test + fun `a 400 drops the batch permanently`() { + gateway.scriptTelemetry(HTTP_BAD_REQUEST) + val sender = sender() + sender.bind(SCOPE_A) + + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + flush(sender) + awaitBatches(1) + + // Terminal: never retried, and the buffer is cleared rather than left to be refused again. + awaitDropped(sender, 1) + awaitBuffer { it.isEmpty() } + assertEquals(0, sender.pendingEntryCount) + scheduler.runAll() + worker.runAll() + assertEquals(1, gateway.batches.size) + } + + @Test + fun `a 401 re-bootstraps exactly once and retries the batch`() { + gateway.scriptTelemetry(HTTP_UNAUTHORIZED) + val sender = sender() + sender.bind(SCOPE_A) + + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + flush(sender) + awaitBatches(2) + + assertEquals(1, gateway.sessions.size) + assertEquals(listOf(SEEDED_SESSION_TOKEN, SESSION_TOKEN), gateway.batches.map { it.sessionHeader }) + assertEquals(0, sender.droppedEventCount) + // No retry was scheduled: the re-bootstrap is the transport's business, not the sender's + // retry budget. + assertEquals(emptyList(), scheduler.requestedDelays.filter { it != REMOTE_CONFIG_TELEMETRY_TICK_MILLIS }) + } + + @Test + fun `a 401 from the telemetry route never forgets the shared session`() { + gateway.scriptTelemetry(HTTP_UNAUTHORIZED, HTTP_UNAUTHORIZED) + val sender = sender() + sender.bind(SCOPE_A) + + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + flush(sender) + awaitBatches(2) + awaitDropped(sender, 1) + + // The stored session belongs to the config read path; an out-of-band signal may not + // invalidate it. The re-bootstrap replaced it, it was never cleared. + val stored = sessionStore.load(RemoteConfigSessionKey(SCOPE_A, SCOPE_A.canonicalUserId)) + assertNotNull("the telemetry 401 forgot the config read path's session", stored) + assertEquals(SESSION_TOKEN, stored?.token) + } + + @Test + fun `a 503 is retried to the attempt bound and then abandoned for the process`() { + gateway.scriptTelemetry(*IntArray(RETRY_SCRIPT_SIZE) { HTTP_SERVICE_UNAVAILABLE }) + val sender = sender() + sender.bind(SCOPE_A) + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + flush(sender) + + runRetryLadder() + + assertEquals(REMOTE_CONFIG_TELEMETRY_MAX_ATTEMPTS, gateway.batches.size) + // Half the cap plus fixed jitter, doubling: 750, 1500. + assertEquals(listOf(750L, 1_500L), retryDelays()) + scheduler.runAll() + worker.runAll() + assertEquals(REMOTE_CONFIG_TELEMETRY_MAX_ATTEMPTS, gateway.batches.size) + // Abandoned in this process, still owed: the durable buffer is what a later start picks up. + awaitBuffer { it.size == 1 } + } + + @Test + fun `an exhausted ladder is not re-armed by a re-binding`() { + gateway.scriptTelemetry(*IntArray(RETRY_SCRIPT_SIZE) { HTTP_SERVICE_UNAVAILABLE }) + val sender = sender() + sender.bind(SCOPE_A) + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + flush(sender) + runRetryLadder() + + // An identify that lands on the same identity, and a new event for it. + sender.bind(SCOPE_A) + sender.recordDecodeFailure("onboarding", RELEASE_7) + flush(sender) + + assertEquals(REMOTE_CONFIG_TELEMETRY_MAX_ATTEMPTS, gateway.batches.size) + } + + @Test + fun `a buffered batch survives a process restart`() { + gateway.scriptTelemetry(*IntArray(RETRY_SCRIPT_SIZE) { HTTP_SERVICE_UNAVAILABLE }) + val crashed = sender() + crashed.bind(SCOPE_A) + crashed.recordDecodeFailure("paywall_prices", RELEASE_7) + flush(crashed) + runRetryLadder() + + // A new process: new sender, new scheduler, new worker, same durable store. + gateway.clearScript() + scheduler = ManualScheduler() + worker = ManualExecutor() + val restarted = sender() + restarted.bind(SCOPE_A) + worker.runAll() + + awaitBatches(REMOTE_CONFIG_TELEMETRY_MAX_ATTEMPTS + 1) + // The event still reports when it OCCURRED, not when it was finally delivered. + assertEquals( + "{\"events\":[{\"kind\":\"decode_failure\",\"logical_key\":\"paywall_prices\"," + + "\"release_number\":$RELEASE_7,\"count\":1,\"last_occurred_at\":$OCCURRED_AT_SECONDS}]}", + gateway.batches.last().body, + ) + awaitBuffer { it.isEmpty() } + } + + @Test + fun `a batch is never sent under another identity's session`() { + val sender = sender() + sender.bind(SCOPE_A) + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + // The transport now addresses another identity than the one the events were buffered for. + identityScope = SCOPE_B + + flush(sender) + + assertEquals(emptyList(), gateway.batches) + // Not an attempt: no retry budget was spent and the buffer stays queued. + assertEquals(0, sender.droppedEventCount) + assertEquals(1, sender.pendingEntryCount) + assertEquals(emptyList(), retryDelays()) + } + + @Test + fun `binding another identity drops the previous identity's buffer from memory`() { + val sender = sender() + sender.bind(SCOPE_A) + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + worker.runAll() + + identityScope = SCOPE_B + sender.bind(SCOPE_B) + + assertEquals(0, sender.pendingEntryCount) + // Still owed by the identity that produced it, and only by that one. + assertEquals(1, store.load(SCOPE_A).size) + flush(sender) + assertEquals(emptyList(), gateway.batches) + } + + @Test + fun `occurrences that arrive while a batch is on the wire are not lost`() { + val onTheWire = CountDownLatch(1) + gateway.parkBatchesOn(onTheWire) + val sender = sender() + sender.bind(SCOPE_A) + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + flush(sender) + awaitBatches(1) + + // The same key fails twice more while the first batch is still unanswered. + repeat(2) { sender.recordDecodeFailure("paywall_prices", RELEASE_7) } + gateway.parkBatchesOn(null) + onTheWire.countDown() + + // Delivering a batch leaves a non-empty buffer, so the remainder follows on its own. + awaitBatches(2) + // The delivered count is subtracted, not cleared: 3 - 1 = 2 still to report. + assertEquals(2, gateway.batches[1].count()) + awaitBuffer { it.isEmpty() } + } + + @Test + fun `recording never touches the durable store on the caller's thread`() { + val sender = sender() + sender.bind(SCOPE_A) + + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + sender.record(RemoteConfigReadGuardEvent.ReadBeforeActivate) + + // Nothing has run on the worker yet, so nothing can have been written or sent. + assertEquals(emptyList(), gateway.batches) + assertTrue(store.load(SCOPE_A).isEmpty()) + assertEquals(2, sender.pendingEntryCount) + worker.runAll() + assertEquals(2, store.load(SCOPE_A).size) + } + + @Test + fun `a durable write that fails never costs an in-memory event`() { + store.failWrites = true + val sender = sender() + sender.bind(SCOPE_A) + + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + flush(sender) + + awaitBatches(1) + assertEquals(1, gateway.batches.single().eventCount()) + assertEquals(0, sender.droppedEventCount) + } + + @Test + fun `a rejected worker leaves the buffer intact for the next attempt`() { + worker.reject = true + val sender = sender() + sender.bind(SCOPE_A) + + sender.recordDecodeFailure("paywall_prices", RELEASE_7) + + assertEquals(1, sender.pendingEntryCount) + assertEquals(0, sender.droppedEventCount) + worker.reject = false + flush(sender) + awaitBatches(1) + } + + private fun sender( + maxEntries: Int = REMOTE_CONFIG_TELEMETRY_MAX_ENTRIES, + maxBatchEvents: Int = TEST_MAX_BATCH_EVENTS, + maxPreBindEvents: Int = REMOTE_CONFIG_TELEMETRY_MAX_PRE_BIND_EVENTS, + ) = RemoteConfigTelemetrySender( + transport = transport(), + store = store, + clock = clock, + random = { 0.5 }, + scheduler = scheduler, + executor = worker, + maxEntries = maxEntries, + flushThreshold = TEST_FLUSH_THRESHOLD, + maxBatchEvents = maxBatchEvents, + maxPreBindEvents = maxPreBindEvents, + ) + + private fun transport() = RemoteConfigGatewayTransport( + callFactory = OkHttpClient().also(clients::add), + baseUrlProvider = { server.url("/").toString() }, + identityProvider = { + identityScope?.let { scope -> + RemoteConfigTransportIdentity(scope, PROJECT_TOKEN, scope.canonicalUserId) + } + }, + clientContextProvider = { null }, + sessionStore = sessionStore, + projectIds = RemoteConfigProjectIdRegistry(InMemoryProjectIdStore()), + clock = clock, + moshi = Moshi.Builder().build(), + logger = SilentLogger(), + ) + + /** Asks for a flush and lets the worker take it, the way the shipped wiring does. */ + private fun flush(sender: RemoteConfigTelemetrySender) { + sender.onSuccessfulFetch() + worker.runAll() + } + + /** Walks the bounded retry ladder to its end, without racing the timer it arms. */ + private fun runRetryLadder() { + repeat(REMOTE_CONFIG_TELEMETRY_MAX_ATTEMPTS - 1) { attempt -> + awaitBatches(attempt + 1) + // Counted rather than "something is pending": the periodic tick is pending too, and + // firing it instead of the retry would make the ladder silently stall. + await("no retry was scheduled after attempt ${attempt + 1}") { retryDelays().size > attempt } + scheduler.runAll() + worker.runAll() + } + awaitBatches(REMOTE_CONFIG_TELEMETRY_MAX_ATTEMPTS) + } + + /** Every delay the retry ladder asked for, with the periodic tick filtered out. */ + private fun retryDelays(): List = + scheduler.requestedDelays.filter { it != REMOTE_CONFIG_TELEMETRY_TICK_MILLIS } + + private fun awaitBatches(count: Int) = + await("expected $count telemetry batches, saw ${gateway.batches.size}") { + gateway.batches.size >= count + } + + private fun awaitDropped(sender: RemoteConfigTelemetrySender, count: Long) = + await("expected $count dropped events, saw ${sender.droppedEventCount}") { + sender.droppedEventCount >= count + } + + private fun awaitBuffer(predicate: (List) -> Boolean) = + await("durable telemetry buffer never reached the expected shape: ${store.load(SCOPE_A)}") { + predicate(store.load(SCOPE_A)) + } + + private fun await(message: String, condition: () -> Boolean) { + val deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(RC_AWAIT_SECONDS) + while (System.currentTimeMillis() < deadline) { + if (condition()) return + Thread.sleep(POLL_INTERVAL_MILLIS) + } + throw AssertionError(message) + } + + private class MutableTelemetryClock(@Volatile var now: Long) : RemoteConfigFetchClock { + override fun nowMillis(): Long = now + } + + /** + * A worker whose tasks only run when the test says so. + * + * This is what makes "a handler invoked from the read path only enqueues and returns" + * observable: anything the sender does off this executor happened on the caller's thread. + */ + private class ManualExecutor : Executor { + private val tasks = ArrayDeque() + + @Volatile + var reject: Boolean = false + + override fun execute(command: Runnable) { + if (reject) throw RejectedExecutionException("worker is down") + synchronized(tasks) { tasks.addLast(command) } + } + + fun runAll() { + while (true) { + val next = synchronized(tasks) { tasks.pollFirst() } ?: return + next.run() + } + } + } + + private data class RecordedBatch( + val method: String, + val path: String?, + val body: String, + val sessionHeader: String?, + val authorization: String?, + val contentType: String?, + ) { + fun eventCount(): Int = KIND_PATTERN.findAll(body).count() + + fun kinds(): List = KIND_PATTERN.findAll(body).map { it.groupValues[1] }.toList() + + fun logicalKeys(): List = KEY_PATTERN.findAll(body).map { it.groupValues[1] }.toList() + + fun count(): Long = COUNT_PATTERN.find(body)?.groupValues?.get(1)?.toLong() ?: 0 + } + + /** Answers by path, so a telemetry batch and a bootstrap are never order-coupled. */ + private class ScriptedGateway : Dispatcher() { + val batches: MutableList = Collections.synchronizedList(mutableListOf()) + val sessions: MutableList = Collections.synchronizedList(mutableListOf()) + private val statuses = ArrayDeque() + + @Volatile + private var park: CountDownLatch? = null + + @Volatile + private var parkLimit: Int = Int.MAX_VALUE + + private val parked = java.util.concurrent.atomic.AtomicInteger() + + /** Answers the next batches with [scripted], then `204` forever. */ + fun scriptTelemetry(vararg scripted: Int) { + synchronized(statuses) { scripted.forEach(statuses::addLast) } + } + + fun clearScript() = synchronized(statuses) { statuses.clear() } + + /** + * Accepts a batch and holds its answer until [latch] opens, so a second batch can be + * produced while the first one is genuinely on the wire. + */ + fun parkBatchesOn(latch: CountDownLatch?, limit: Int = Int.MAX_VALUE) { + parked.set(0) + parkLimit = limit + park = latch + } + + /** Releases anything still parked, so a test that models a crash can still shut down. */ + fun releaseAll() { + val parked = park + park = null + parked?.countDown() + } + + override fun dispatch(request: RecordedRequest): MockResponse = when (request.path) { + RC_SESSION_PATH -> { + sessions += request.body.readUtf8() + MockResponse().setResponseCode(HTTP_OK).setBody(sessionBody(sessions.size)) + } + "/$REMOTE_CONFIG_TELEMETRY_PATH" -> { + batches += RecordedBatch( + method = request.method.orEmpty(), + path = request.path, + body = request.body.readUtf8(), + sessionHeader = request.getHeader(REMOTE_CONFIG_SESSION_HEADER), + authorization = request.getHeader("Authorization"), + contentType = request.getHeader("Content-Type"), + ) + park?.takeIf { parked.getAndIncrement() < parkLimit } + ?.await(PARK_TIMEOUT_SECONDS, TimeUnit.SECONDS) + MockResponse().setResponseCode( + synchronized(statuses) { statuses.pollFirst() } ?: HTTP_NO_CONTENT, + ) + } + else -> MockResponse().setResponseCode(HTTP_NOT_FOUND) + } + + private fun sessionBody(ordinal: Int): String { + val token = if (ordinal == 1) SESSION_TOKEN else "$SESSION_TOKEN-$ordinal" + return "{\"session_token\":\"$token\",\"project_id\":$RC_PROJECT_ID," + + "\"environment\":\"prod\",\"expires_at\":\"2030-01-01T00:00:00Z\"}" + } + + private companion object { + const val PARK_TIMEOUT_SECONDS = 5L + } + } + + private companion object { + val KIND_PATTERN = Regex("\"kind\":\"([a-z_]+)\"") + val KEY_PATTERN = Regex("\"logical_key\":\"([^\"]+)\"") + val COUNT_PATTERN = Regex("\"count\":(\\d+)") + val SCOPE_A = RemoteConfigSnapshotScope(RC_PROJECT_KEY, RC_ENVIRONMENT, "QON_anon_a") + val SCOPE_B = RemoteConfigSnapshotScope(RC_PROJECT_KEY, RC_ENVIRONMENT, "QON_anon_b") + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigTelemetryTransportTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigTelemetryTransportTest.kt new file mode 100644 index 000000000..e0447dcca --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigTelemetryTransportTest.kt @@ -0,0 +1,274 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import com.squareup.moshi.Moshi +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.ArrayDeque +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +private const val PROJECT_TOKEN = "project-token" +private const val SESSION_TOKEN = "qrcs1.session" +private const val SEEDED_SESSION_TOKEN = "qrcs1.seeded-session" +private const val SESSION_LIFETIME_MILLIS = 3_600_000L +private const val NOW_MILLIS = 1_700_000_000_000L +private const val OCCURRED_AT_SECONDS = 1_700_000_000L +private const val RELEASE_7 = 7L +private const val HTTP_BAD_REQUEST = 400 + +/** + * The `/v3/remote-config-v2/telemetry` route of [RemoteConfigGatewayTransport], over a real + * [MockWebServer]. + * + * These tests are about the ROUTE, not the queue: what the transport puts on the wire, how it + * classifies an answer, and — the rule that has teeth — what a `401` may and may not do to the + * session the config read path depends on. + */ +internal class RemoteConfigTelemetryTransportTest { + private lateinit var server: MockWebServer + private lateinit var client: OkHttpClient + private lateinit var gateway: ScriptedGateway + private lateinit var sessionStore: InMemorySessionStore + private var identityScope: RemoteConfigSnapshotScope? = SCOPE_A + + @Before + fun setUp() { + server = MockWebServer() + gateway = ScriptedGateway() + server.dispatcher = gateway + server.start() + client = OkHttpClient() + identityScope = SCOPE_A + sessionStore = InMemorySessionStore() + sessionStore.save( + RemoteConfigSessionKey(SCOPE_A, SCOPE_A.canonicalUserId), + RemoteConfigGatewaySession( + token = SEEDED_SESSION_TOKEN, + projectId = RC_PROJECT_ID, + environment = "prod", + expiresAtMillis = NOW_MILLIS + SESSION_LIFETIME_MILLIS, + ), + ) + } + + @After + fun tearDown() { + try { + server.shutdown() + } catch (_: Exception) { + // One test shuts the gateway down itself to produce a transport fault. + } + client.dispatcher().executorService().shutdownNow() + client.connectionPool().evictAll() + } + + @Test + fun `a batch is posted to the telemetry path with the session header`() { + val response = post(listOf(decodeFailure("paywall_prices"), guardEvent())) + + assertEquals(RemoteConfigAckResponse.Delivered, response) + val recorded = gateway.batches.single() + assertEquals("POST", recorded.method) + assertEquals(RC_TELEMETRY_PATH, recorded.path) + assertEquals("Bearer $PROJECT_TOKEN", recorded.authorization) + assertEquals(SEEDED_SESSION_TOKEN, recorded.sessionHeader) + assertEquals("no-store", recorded.cacheControl) + // Exactly the contract shape: logical_key present iff the kind is decode_failure. + assertEquals( + "{\"events\":[" + + "{\"kind\":\"decode_failure\",\"logical_key\":\"paywall_prices\"," + + "\"release_number\":$RELEASE_7,\"count\":1,\"last_occurred_at\":$OCCURRED_AT_SECONDS}," + + "{\"kind\":\"read_before_activate\",\"release_number\":0," + + "\"count\":2,\"last_occurred_at\":$OCCURRED_AT_SECONDS}" + + "]}", + recorded.body, + ) + } + + @Test + fun `a missing session is never bootstrapped by the telemetry route`() { + sessionStore.clear(RemoteConfigSessionKey(SCOPE_A, SCOPE_A.canonicalUserId)) + + val response = post(listOf(decodeFailure("paywall_prices"))) + + // Session establishment belongs to the config read path. A diagnostic signal must not be + // the reason an installation contacts the gateway, so the batch is refused as + // NotAddressable — which costs the sender no retry budget and keeps the events buffered. + assertEquals(RemoteConfigAckResponse.NotAddressable, response) + assertTrue(gateway.sessions.isEmpty()) + assertTrue(gateway.batches.isEmpty()) + } + + @Test + fun `a 401 re-bootstraps once and never forgets the shared session`() { + gateway.script(HTTP_UNAUTHORIZED) + + val response = post(listOf(decodeFailure("paywall_prices"))) + + assertEquals(RemoteConfigAckResponse.Delivered, response) + assertEquals(1, gateway.sessions.size) + assertEquals(listOf(SEEDED_SESSION_TOKEN, SESSION_TOKEN), gateway.batches.map { it.sessionHeader }) + // The stored session belongs to the config read path: telemetry replaces it by minting, it + // never clears it. + val stored = sessionStore.load(RemoteConfigSessionKey(SCOPE_A, SCOPE_A.canonicalUserId)) + assertNotNull("the telemetry route forgot the config read path's session", stored) + assertEquals(SESSION_TOKEN, stored?.token) + } + + @Test + fun `a second 401 is permanent and still leaves the session alone`() { + gateway.script(HTTP_UNAUTHORIZED, HTTP_UNAUTHORIZED) + + val response = post(listOf(decodeFailure("paywall_prices"))) + + assertEquals(RemoteConfigAckResponse.Permanent, response) + assertEquals(2, gateway.batches.size) + assertNotNull(sessionStore.load(RemoteConfigSessionKey(SCOPE_A, SCOPE_A.canonicalUserId))) + } + + @Test + fun `statuses are classified exactly like the ack route`() { + assertEquals(RemoteConfigAckResponse.Permanent, postWith(HTTP_BAD_REQUEST)) + assertEquals(RemoteConfigAckResponse.Permanent, postWith(HTTP_NOT_FOUND)) + assertEquals(RemoteConfigAckResponse.Retryable, postWith(HTTP_SERVICE_UNAVAILABLE)) + assertEquals(RemoteConfigAckResponse.Retryable, postWith(HTTP_SERVER_ERROR)) + assertEquals(RemoteConfigAckResponse.Delivered, postWith(HTTP_NO_CONTENT)) + } + + @Test + fun `a batch is never posted under another identity's session`() { + identityScope = SCOPE_B + + val response = post(listOf(decodeFailure("paywall_prices"))) + + // Not an attempt at all, so it costs the sender no retry budget. + assertEquals(RemoteConfigAckResponse.NotAddressable, response) + assertTrue(gateway.batches.isEmpty()) + assertTrue(gateway.sessions.isEmpty()) + } + + @Test + fun `a batch the gateway could never accept is refused without a request`() { + assertEquals(RemoteConfigAckResponse.Permanent, post(emptyList())) + assertEquals( + RemoteConfigAckResponse.Permanent, + post(List(REMOTE_CONFIG_TELEMETRY_MAX_BATCH_EVENTS + 1) { decodeFailure("key-$it") }), + ) + assertTrue(gateway.batches.isEmpty()) + } + + @Test + fun `a transport fault is retryable`() { + server.shutdown() + + assertEquals(RemoteConfigAckResponse.Retryable, post(listOf(decodeFailure("paywall_prices")))) + } + + private fun postWith(statusCode: Int): RemoteConfigAckResponse { + gateway.script(statusCode) + return post(listOf(decodeFailure("paywall_prices"))) + } + + private fun post(events: List): RemoteConfigAckResponse { + val latch = CountDownLatch(1) + val result = AtomicReference() + transport().postTelemetry(SCOPE_A, events) { response -> + result.set(response) + latch.countDown() + } + assertTrue("the telemetry post never completed", latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + return requireNotNull(result.get()) + } + + private fun decodeFailure(key: String) = RemoteConfigTelemetryEvent( + kind = RemoteConfigTelemetryKind.DecodeFailure, + logicalKey = key, + releaseNumber = RELEASE_7, + count = 1, + lastOccurredAtSeconds = OCCURRED_AT_SECONDS, + ) + + private fun guardEvent() = RemoteConfigTelemetryEvent( + kind = RemoteConfigTelemetryKind.ReadBeforeActivate, + logicalKey = "", + releaseNumber = 0, + count = 2, + lastOccurredAtSeconds = OCCURRED_AT_SECONDS, + ) + + private fun transport() = RemoteConfigGatewayTransport( + callFactory = client, + baseUrlProvider = { server.url("/").toString() }, + identityProvider = { + identityScope?.let { scope -> + RemoteConfigTransportIdentity(scope, PROJECT_TOKEN, scope.canonicalUserId) + } + }, + clientContextProvider = { null }, + sessionStore = sessionStore, + projectIds = RemoteConfigProjectIdRegistry(InMemoryProjectIdStore()), + clock = { NOW_MILLIS }, + moshi = Moshi.Builder().build(), + logger = SilentLogger(), + ) + + private data class RecordedBatch( + val method: String, + val path: String?, + val body: String, + val sessionHeader: String?, + val authorization: String?, + val cacheControl: String?, + ) + + private class ScriptedGateway : Dispatcher() { + val batches: MutableList = Collections.synchronizedList(mutableListOf()) + val sessions: MutableList = Collections.synchronizedList(mutableListOf()) + private val statuses = ArrayDeque() + + /** Answers the next batches with [scripted], then `204` forever. */ + fun script(vararg scripted: Int) { + synchronized(statuses) { scripted.forEach(statuses::addLast) } + } + + override fun dispatch(request: RecordedRequest): MockResponse = when (request.path) { + RC_SESSION_PATH -> { + sessions += request.body.readUtf8() + MockResponse().setResponseCode(HTTP_OK).setBody( + "{\"session_token\":\"$SESSION_TOKEN\",\"project_id\":$RC_PROJECT_ID," + + "\"environment\":\"prod\",\"expires_at\":\"2030-01-01T00:00:00Z\"}", + ) + } + RC_TELEMETRY_PATH -> { + batches += RecordedBatch( + method = request.method.orEmpty(), + path = request.path, + body = request.body.readUtf8(), + sessionHeader = request.getHeader(REMOTE_CONFIG_SESSION_HEADER), + authorization = request.getHeader("Authorization"), + cacheControl = request.getHeader("Cache-Control"), + ) + MockResponse().setResponseCode( + synchronized(statuses) { statuses.pollFirst() } ?: HTTP_NO_CONTENT, + ) + } + else -> MockResponse().setResponseCode(HTTP_NOT_FOUND) + } + } + + private companion object { + val SCOPE_A = RemoteConfigSnapshotScope(RC_PROJECT_KEY, RC_ENVIRONMENT, "QON_anon_a") + val SCOPE_B = RemoteConfigSnapshotScope(RC_PROJECT_KEY, RC_ENVIRONMENT, "QON_anon_b") + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2DeviceScopeTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2DeviceScopeTest.kt new file mode 100644 index 000000000..1b8049611 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2DeviceScopeTest.kt @@ -0,0 +1,70 @@ +@file:OptIn(ExperimentalQonversionApi::class) + +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import java.util.concurrent.TimeUnit + +private const val FIRST_INSTALL_TIME_MILLIS = 1_577_836_800_123L +private const val EXPECTED_INSTALLED_AT_SECONDS = 1_577_836_800L +private const val POLL_INTERVAL_MILLIS = 10L + +/** + * Proves the device-scoped part of the client context end to end, through the public fetch path. + * + * The provider under test is the real one, reading a real (Robolectric) `PackageManager`: an + * identity switch must not move `device_installed_at`, because the server evaluates account age as + * `min(device_installed_at, client.created_at)` and a post-logout client row is always brand new. + */ +@RunWith(RobolectricTestRunner::class) +internal class RemoteConfigV2DeviceScopeTest { + private var harness: RemoteConfigV2Harness? = null + + @After + fun tearDown() { + harness?.shutdown() + } + + @Test + fun `device_installed_at is identical across an identity switch`() { + val application = RuntimeEnvironment.getApplication() + shadowOf(application.packageManager) + .getInternalMutablePackageInfo(application.packageName) + .apply { + firstInstallTime = FIRST_INSTALL_TIME_MILLIS + versionName = "1.2.3" + } + val started = RemoteConfigV2Harness( + clientContextProvider = DeviceRemoteConfigClientContextProvider(application, "9.7.0"), + ).also { harness = it } + + started.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + started.fetchBlocking() + started.identify("QON_anon_b", "canonical-b", RemoteConfigFetchForceReason.Logout) + awaitSnapshotReads(started, count = 2) + + val installedAt = started.snapshotRequests.map { body -> + Regex("\"device_installed_at\":(\\d+)").find(body)?.groupValues?.get(1) + } + assertTrue("expected a snapshot read per identity", installedAt.size >= 2) + // Without this the test would also pass if the identity switch had silently no-op'd. + assertTrue(started.sessionRequests.any { it.contains("QON_anon_a") }) + assertTrue(started.sessionRequests.any { it.contains("QON_anon_b") }) + assertEquals(setOf(EXPECTED_INSTALLED_AT_SECONDS.toString()), installedAt.toSet()) + } + + private fun awaitSnapshotReads(harness: RemoteConfigV2Harness, count: Int) { + val deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(RC_AWAIT_SECONDS) + while (harness.snapshotRequests.size < count && System.currentTimeMillis() < deadline) { + Thread.sleep(POLL_INTERVAL_MILLIS) + } + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2FetchIntervalTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2FetchIntervalTest.kt new file mode 100644 index 000000000..3ec14eb08 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2FetchIntervalTest.kt @@ -0,0 +1,124 @@ +@file:OptIn(ExperimentalQonversionApi::class) + +package com.qonversion.android.sdk.internal.remoteconfig + +import android.content.pm.ApplicationInfo +import com.qonversion.android.sdk.ExperimentalQonversionApi +import com.qonversion.android.sdk.dto.QEnvironment +import com.qonversion.android.sdk.dto.QLaunchMode +import com.qonversion.android.sdk.dto.entitlements.QEntitlementsCacheLifetime +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigV2Config +import com.qonversion.android.sdk.internal.InternalConfig +import com.qonversion.android.sdk.internal.dto.config.CacheConfig +import com.qonversion.android.sdk.internal.dto.config.PrimaryConfig +import com.qonversion.android.sdk.internal.storage.Cache +import com.squareup.moshi.JsonAdapter +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** + * The minimum-fetch-interval spec (Addendum B): the floor between real network fetches is + * configurable by the app, and a debug build is unthrottled unless the app pins a value. + * + * The policy resolution is a pure function of the configuration and the build mode, so the + * semantics are asserted on [RemoteConfigV2Factory.minimumFetchIntervalMillis] directly; the + * Robolectric test below then pins that [RemoteConfigV2Factory.create] actually feeds the + * application's own debuggable flag and the app's configuration into that function, so the two + * halves cannot drift apart unnoticed. + */ +@RunWith(RobolectricTestRunner::class) +internal class RemoteConfigV2FetchIntervalTest { + + @Test + fun `auto resolves to the production default in a release build`() { + assertEquals(60_000L, RemoteConfigV2Factory.minimumFetchIntervalMillis(config(), isDebuggable = false)) + } + + @Test + fun `auto resolves to no throttling in a debug build`() { + assertEquals(0L, RemoteConfigV2Factory.minimumFetchIntervalMillis(config(), isDebuggable = true)) + } + + @Test + fun `an explicit interval wins over auto in both build modes`() { + val explicit = config(minFetchIntervalSeconds = 300) + + assertEquals(300_000L, RemoteConfigV2Factory.minimumFetchIntervalMillis(explicit, isDebuggable = false)) + assertEquals(300_000L, RemoteConfigV2Factory.minimumFetchIntervalMillis(explicit, isDebuggable = true)) + } + + @Test + fun `the factory feeds the application's debuggable flag into the built coordinator`() { + val application = RuntimeEnvironment.getApplication() + + application.applicationInfo.flags = application.applicationInfo.flags or ApplicationInfo.FLAG_DEBUGGABLE + assertEquals(0L, builtIntervalMillis(config())) + assertEquals(45_000L, builtIntervalMillis(config(minFetchIntervalSeconds = 45))) + + application.applicationInfo.flags = application.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE.inv() + assertEquals(60_000L, builtIntervalMillis(config())) + assertEquals(45_000L, builtIntervalMillis(config(minFetchIntervalSeconds = 45))) + } + + /** + * The interval the factory actually handed to the coordinator, read through the private chain. + * + * Reflection rather than a widened surface on purpose: the coordinator and its policy are + * private to the manager by design, and opening them up for one assertion would trade a + * test-only inconvenience for a production seam. + */ + private fun builtIntervalMillis(config: QRemoteConfigV2Config): Long { + val manager = requireNotNull( + RemoteConfigV2Factory.create( + application = RuntimeEnvironment.getApplication(), + internalConfig = internalConfig(config), + cache = NoOpCache(), + logger = SilentLogger(), + ).manager, + ) + val coordinator = manager.readPrivate("coordinator") + return coordinator.readPrivate("policy").minimumFetchIntervalMillis + } + + private inline fun Any.readPrivate(name: String): T = + javaClass.getDeclaredField(name).apply { isAccessible = true }.get(this) as T + + private fun internalConfig(remoteConfigV2Config: QRemoteConfigV2Config) = InternalConfig( + primaryConfig = PrimaryConfig( + projectKey = "project-key", + launchMode = QLaunchMode.SubscriptionManagement, + environment = QEnvironment.Sandbox, + ), + cacheConfig = CacheConfig( + entitlementsCacheLifetime = QEntitlementsCacheLifetime.Month, + fallbackFileIdentifier = null, + ), + remoteConfigV2Config = remoteConfigV2Config, + ) + + private fun config(minFetchIntervalSeconds: Long = 0) = QRemoteConfigV2Config( + baseUrl = "https://rc.example.invalid/", + environmentUid = "production", + minFetchIntervalSeconds = minFetchIntervalSeconds, + ) + + private class NoOpCache : Cache { + override fun putInt(key: String, value: Int) = Unit + override fun getInt(key: String, defValue: Int): Int = defValue + override fun getBool(key: String, defValue: Boolean): Boolean = defValue + override fun putBool(key: String, value: Boolean) = Unit + override fun putFloat(key: String, value: Float) = Unit + override fun getFloat(key: String, defValue: Float): Float = defValue + override fun putLong(key: String, value: Long) = Unit + override fun getLong(key: String, defValue: Long): Long = defValue + override fun putString(key: String, value: String?) = Unit + override fun getString(key: String, defValue: String?): String? = defValue + override fun remove(key: String) = Unit + override fun updateStringsDurably(values: Map, removedKeys: Set): Boolean = true + override fun putObject(key: String, value: T, adapter: JsonAdapter) = Unit + override fun getObject(key: String, adapter: JsonAdapter): T? = null + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt new file mode 100644 index 000000000..c11e33712 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt @@ -0,0 +1,655 @@ +@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.QRemoteConfigUpdate +import com.qonversion.android.sdk.internal.logger.Logger +import com.qonversion.android.sdk.internal.services.decodePortableRemoteConfigJson +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 com.squareup.moshi.Moshi +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okio.Buffer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import java.security.MessageDigest +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +internal const val RC_PROJECT_KEY = "project-key" +internal const val RC_ENVIRONMENT = "production" +internal const val RC_PROJECT_ID = 42L +internal const val RC_FINGERPRINT_LENGTH = 64 +internal const val RC_AWAIT_SECONDS = 10L +internal const val RC_MAIN_THREAD_NAME = "qonversion-test-main" +internal const val RC_SESSION_PATH = "/v3/remote-config-v2/session" +internal const val RC_SNAPSHOT_PATH = "/v3/remote-config-v2/snapshot" +internal const val RC_ACK_PATH = "/v3/remote-config-v2/ack" +internal const val RC_TELEMETRY_PATH = "/v3/remote-config-v2/telemetry" +internal const val RC_DEVICE_INSTALLED_AT = 1_577_836_800L +internal const val HTTP_OK = 200 +internal const val HTTP_NO_CONTENT = 204 +internal const val HTTP_NOT_MODIFIED = 304 +internal const val HTTP_UNAUTHORIZED = 401 +internal const val HTTP_NOT_FOUND = 404 +internal const val HTTP_SERVER_ERROR = 500 +internal const val HTTP_SERVICE_UNAVAILABLE = 503 + +/** One activation ack as the gateway saw it. */ +internal data class RcRecordedAck( + val body: String, + val sessionHeader: String?, + val authorization: String?, +) + +internal val RC_FINGERPRINT = "a".repeat(RC_FINGERPRINT_LENGTH) + +/** One value of a scripted snapshot release. */ +internal data class RcWireValue( + val key: String, + val raw: String, + val applyPolicy: String = "on_next_activate", + val metadata: String = "null", +) { + fun toJson(): String = "\"$key\":{\"raw\":$raw,\"variation_uid\":\"var-$key-$applyPolicy\"," + + "\"apply_policy\":\"$applyPolicy\",\"metadata\":$metadata}" +} + +internal fun rcWireBody( + releaseUid: String, + releaseNumber: Long, + values: List, + contextFingerprint: String = RC_FINGERPRINT, +): String = "{\"schema_version\":1,\"project_id\":$RC_PROJECT_ID," + + "\"environment_uid\":\"$RC_ENVIRONMENT\",\"release_uid\":\"$releaseUid\"," + + "\"release_number\":$releaseNumber,\"manifest_content_hash\":\"${"1".repeat(RC_FINGERPRINT_LENGTH)}\"," + + "\"complete_key_set\":true,\"context_fingerprint\":\"$contextFingerprint\"," + + "\"values\":{${values.joinToString(",") { it.toJson() }}}}" + +internal fun rcStrongETag(body: ByteArray): String = MessageDigest.getInstance("SHA-256") + .digest(body) + .joinToString(prefix = "\"", postfix = "\"", separator = "") { byte -> "%02x".format(byte) } + +/** + * The Remote Config defaults "bundled with the app" for these tests. + * + * `count` is also served by every scripted release, so it can be observed at all three ladder + * positions; `bundled_only` exists nowhere else, so it can only ever resolve to the fallback. + */ +internal fun rcBundledRelease() = RemoteConfigScopedBundledRelease( + projectKey = RC_PROJECT_KEY, + environment = RC_ENVIRONMENT, + release = RemoteConfigSnapshotRelease( + releaseUid = "bundled-release", + releaseNumber = 1, + manifestContentHash = "2".repeat(RC_FINGERPRINT_LENGTH), + entries = listOf( + RemoteConfigSnapshotEntry.value( + key = "count", + rawValue = "0".encodeToByteArray(), + variationUid = "bundled-count", + applyPolicy = RemoteConfigSnapshotApplyPolicy.OnNextActivate, + metadata = null, + ), + RemoteConfigSnapshotEntry.value( + key = "bundled_only", + rawValue = "\"bundled\"".encodeToByteArray(), + variationUid = "bundled-only", + applyPolicy = RemoteConfigSnapshotApplyPolicy.OnNextActivate, + metadata = null, + ), + ), + ), +) + +/** + * Builds the real chain behind the public API — snapshot core, read guard, fetch coordinator and + * the gateway transport over a real [MockWebServer] — so the tests exercise the shipped wiring + * rather than a mock of it. + * + * Only three things are test doubles, each for determinism rather than convenience: the snapshot / + * policy stores are in memory, the fetch timeout scheduler is manual, and the "main thread" is a + * single named executor so callback threading can be asserted. + */ +@Suppress("LongParameterList") +internal class RemoteConfigV2Harness( + buildMode: RemoteConfigReadBuildMode = RemoteConfigReadBuildMode.Debug, + bundled: RemoteConfigScopedBundledRelease? = rcBundledRelease(), + defaultFetchTimeoutMillis: Long = 0, + minimumFetchIntervalMillis: Long = 0, + // Handed in so a "process restart" can be modelled as a second harness over the same durable + // state, which is the only honest way to test that a queued ack survives one. + val snapshotStore: InMemorySnapshotStore = InMemorySnapshotStore(), + val ackStore: InMemoryActivationAckStore = InMemoryActivationAckStore(), + val telemetryStore: InMemoryTelemetryStore = InMemoryTelemetryStore(), + clientContextProvider: RemoteConfigClientContextProvider = RemoteConfigClientContextProvider { + RemoteConfigClientContext( + platform = "android", + appVersion = "1.2.3", + osVersion = "14", + sdkVersion = "9.7.0", + locale = "en_US", + deviceModel = "Pixel 8", + deviceInstalledAtSeconds = RC_DEVICE_INSTALLED_AT, + ) + }, +) { + private val bundledEntries = bundled?.release + private val httpClient = OkHttpClient() + val server = MockWebServer() + val timeoutScheduler = ManualScheduler() + val assertions: MutableList = Collections.synchronizedList(mutableListOf()) + val guardEvents: MutableList = Collections.synchronizedList(mutableListOf()) + val snapshotRequests: MutableList = Collections.synchronizedList(mutableListOf()) + val sessionRequests: MutableList = Collections.synchronizedList(mutableListOf()) + val ackRequests: MutableList = Collections.synchronizedList(mutableListOf()) + val telemetryRequests: MutableList = Collections.synchronizedList(mutableListOf()) + + @Volatile + var userUid: String = "QON_anon_a" + + private val body = AtomicReference(defaultBody()) + private val hang = AtomicReference(false) + private val responseDelayMillis = AtomicReference(0L) + private val snapshotStatusCode = AtomicReference(HTTP_OK) + private val ackStatusCode = AtomicReference(HTTP_NO_CONTENT) + private val telemetryStatusCode = AtomicReference(HTTP_NO_CONTENT) + private val hangAcks = AtomicReference(false) + private val contextFingerprint = AtomicReference(RC_FINGERPRINT) + private val worker: ExecutorService = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "qonversion-test-worker") + } + private val mainExecutor: ExecutorService = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, RC_MAIN_THREAD_NAME) + } + private val scopeHolder = RemoteConfigV2ScopeHolder() + private val mainDispatcher = RemoteConfigMainDispatcher { action -> mainExecutor.execute(action) } + + val transport = RemoteConfigGatewayTransport( + callFactory = httpClient, + baseUrlProvider = { server.url("/").toString() }, + identityProvider = { + scopeHolder.scope?.let { scope -> + RemoteConfigTransportIdentity(scope, "project-token", userUid) + } + }, + clientContextProvider = clientContextProvider, + sessionStore = InMemorySessionStore(), + projectIds = RemoteConfigProjectIdRegistry(InMemoryProjectIdStore()), + clock = { System.currentTimeMillis() }, + moshi = Moshi.Builder().build(), + logger = SilentLogger(), + ) + + val ackScheduler = ManualScheduler() + + val ackSender = RemoteConfigActivationAckSender( + transport = transport, + store = ackStore, + clock = { System.currentTimeMillis() }, + random = { 0.5 }, + scheduler = ackScheduler, + ) + + val telemetryScheduler = ManualScheduler() + + val telemetrySender = RemoteConfigTelemetrySender( + transport = transport, + store = telemetryStore, + clock = { System.currentTimeMillis() }, + random = { 0.5 }, + scheduler = telemetryScheduler, + executor = worker, + ) + + val core = RemoteConfigSnapshotCore( + store = snapshotStore, + bundledRelease = bundled, + decodeFailureObserver = telemetrySender::recordDecodeFailure, + ) + + private val readGuard = RemoteConfigReadGuard( + core = core, + preloader = PersistentRemoteConfigReadPreloader(snapshotStore, worker), + buildMode = buildMode, + assertion = { message -> assertions += message }, + telemetry = { event -> + guardEvents += event + telemetrySender.record(event) + }, + ) + + val coordinator = RemoteConfigFetchCoordinator( + core = core, + transport = transport, + policyStore = InMemoryFetchPolicyStore(), + clock = { System.currentTimeMillis() }, + random = { 0.5 }, + // The coordinator's own timeout is disabled: the public API's per-call timeout is the + // behaviour under test, and a second timer would make which one fired ambiguous. + scheduler = { _, _ -> RemoteConfigFetchScheduledTask { } }, + policy = RemoteConfigFetchPolicy( + minimumFetchIntervalMillis = minimumFetchIntervalMillis, + timeoutMillis = null, + ), + policyPersistenceFailureObserver = { telemetrySender.recordPolicyPersistenceFailure() }, + ) + + val manager = RemoteConfigV2Manager( + core = core, + readGuard = readGuard, + coordinator = coordinator, + ackSender = ackSender, + telemetrySender = telemetrySender, + options = RemoteConfigV2Options(RC_PROJECT_KEY, RC_ENVIRONMENT), + scopeHolder = scopeHolder, + scheduler = timeoutScheduler, + worker = worker, + mainDispatcher = mainDispatcher, + logger = SilentLogger(), + defaultFetchTimeoutMillis = defaultFetchTimeoutMillis, + ) + + val configs: QRemoteConfigSnapshots = QRemoteConfigSnapshotsImpl( + manager = manager, + bundledValueReader = { contextKey -> fallbackRemoteConfigValue(contextKey) }, + mainDispatcher = mainDispatcher, + ) + + init { + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = when (request.path) { + RC_SESSION_PATH -> { + sessionRequests += request.body.readUtf8() + sessionResponse() + } + RC_SNAPSHOT_PATH -> { + snapshotRequests += request.body.readUtf8() + snapshotResponse() + } + RC_ACK_PATH -> { + ackRequests += RcRecordedAck( + body = request.body.readUtf8(), + sessionHeader = request.getHeader(REMOTE_CONFIG_SESSION_HEADER), + authorization = request.getHeader("Authorization"), + ) + if (hangAcks.get()) { + MockResponse().setSocketPolicy(okhttp3.mockwebserver.SocketPolicy.NO_RESPONSE) + } else { + MockResponse().setResponseCode(ackStatusCode.get()) + } + } + RC_TELEMETRY_PATH -> { + telemetryRequests += RcRecordedAck( + body = request.body.readUtf8(), + sessionHeader = request.getHeader(REMOTE_CONFIG_SESSION_HEADER), + authorization = request.getHeader("Authorization"), + ) + MockResponse().setResponseCode(telemetryStatusCode.get()) + } + else -> MockResponse().setResponseCode(404) + } + } + server.start() + } + + fun shutdown() { + server.shutdown() + worker.shutdownNow() + mainExecutor.shutdownNow() + httpClient.dispatcher().executorService().shutdownNow() + httpClient.connectionPool().evictAll() + } + + /** Scripts the release the gateway serves from now on. */ + fun serve(releaseUid: String, releaseNumber: Long, values: List) { + body.set(rcWireBody(releaseUid, releaseNumber, values, contextFingerprint.get())) + } + + /** Makes the gateway answer snapshot reads with [statusCode] instead of a release. */ + fun serveStatus(statusCode: Int) = snapshotStatusCode.set(statusCode) + + /** Makes the gateway answer activation acks with [statusCode] instead of `204`. */ + fun serveAckStatus(statusCode: Int) = ackStatusCode.set(statusCode) + + /** Makes the gateway accept activation acks and never answer them, without closing the socket. */ + fun hangAckReads(hanging: Boolean) = hangAcks.set(hanging) + + /** Makes the gateway answer telemetry batches with [statusCode] instead of `204`. */ + fun serveTelemetryStatus(statusCode: Int) = telemetryStatusCode.set(statusCode) + + /** Waits until [count] telemetry batches have reached the gateway. */ + fun awaitTelemetryBatches(count: Int) { + val deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(RC_AWAIT_SECONDS) + while (System.currentTimeMillis() < deadline) { + if (telemetryRequests.size >= count) return + Thread.sleep(POLL_INTERVAL_MILLIS) + } + throw AssertionError("expected $count telemetry batches, saw ${telemetryRequests.size}") + } + + /** Waits until [count] acks have reached the gateway. */ + fun awaitAcks(count: Int) { + val deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(RC_AWAIT_SECONDS) + while (System.currentTimeMillis() < deadline) { + if (ackRequests.size >= count) return + Thread.sleep(POLL_INTERVAL_MILLIS) + } + throw AssertionError("expected $count acks, saw ${ackRequests.size}") + } + + /** + * Rotates the targeting context the gateway reports, as it does for real when the app version, + * locale, purchases, properties or experiment enrollment change. + */ + fun rotateContextFingerprint() { + contextFingerprint.set("b".repeat(RC_FINGERPRINT_LENGTH)) + body.set( + rcWireBody( + releaseUid = "release-rotated", + releaseNumber = ROTATED_RELEASE_NUMBER, + values = listOf(RcWireValue("count", "2")), + contextFingerprint = contextFingerprint.get(), + ), + ) + } + + /** Makes the gateway stop answering snapshot reads, without closing the socket. */ + fun hangSnapshotReads(hanging: Boolean) = hang.set(hanging) + + /** Delays the snapshot answer, so a caller-side timeout can win the race deterministically. */ + fun delaySnapshotReads(millis: Long) = responseDelayMillis.set(millis) + + fun identify(userUid: String, canonicalUserId: String, reason: RemoteConfigFetchForceReason) { + this.userUid = userUid + manager.updateIdentity(canonicalUserId, reason) + } + + fun fetchBlocking(timeoutMs: Long? = null): QRemoteConfigFetchResult { + val latch = CountDownLatch(1) + val result = AtomicReference() + val thread = AtomicReference() + manager.fetch(timeoutMs) { fetchResult -> + thread.set(Thread.currentThread().name) + result.set(fetchResult) + latch.countDown() + } + assertTrue("fetch did not complete", latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(RC_MAIN_THREAD_NAME, thread.get()) + return requireNotNull(result.get()) + } + + fun activateBlocking(): QRemoteConfigActivationResult { + val latch = CountDownLatch(1) + val result = AtomicReference() + val thread = AtomicReference() + manager.activate { activationResult -> + thread.set(Thread.currentThread().name) + result.set(activationResult) + latch.countDown() + } + assertTrue("activation did not complete", latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(RC_MAIN_THREAD_NAME, thread.get()) + return requireNotNull(result.get()) + } + + fun subscribeCollecting(updates: MutableList, latch: CountDownLatch) = + manager.subscribeOnConfigUpdate { update -> + updates += update + latch.countDown() + } + + /** Waits until [releaseNumber] is durably admitted as the fetched candidate. */ + fun awaitCandidate(releaseNumber: Long) { + val deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(RC_AWAIT_SECONDS) + while (System.currentTimeMillis() < deadline) { + if (core.lastFetchedSnapshot()?.releaseNumber == releaseNumber) return + Thread.sleep(POLL_INTERVAL_MILLIS) + } + throw AssertionError("release $releaseNumber was never admitted") + } + + /** Blocks until every task already queued on the background worker has run. */ + fun awaitWorkerIdle() { + val latch = CountDownLatch(1) + worker.execute { latch.countDown() } + assertTrue("worker did not drain", latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + } + + /** + * Reads the same bundled entries the snapshot core resolves against, so the "manual fallback + * getter" and the fallback rung of the ladder can never silently drift apart. + */ + private fun fallbackRemoteConfigValue(contextKey: String): QRemoteConfigFallbackValue? { + val raw = bundledEntries?.entry(contextKey)?.rawValueBytes ?: return null + return QRemoteConfigFallbackValue( + requireNotNull(decodePortableRemoteConfigJson(raw)).value, + ) + } + + private fun defaultBody(fingerprint: String = RC_FINGERPRINT) = + rcWireBody("release-1", 1, listOf(RcWireValue("count", "1")), fingerprint) + + private fun sessionResponse() = MockResponse() + .setResponseCode(200) + .setBody( + "{\"session_token\":\"qrcs1.session-${sessionRequests.size}\",\"project_id\":$RC_PROJECT_ID," + + "\"environment\":\"prod\",\"expires_at\":\"2030-01-01T00:00:00Z\"}", + ) + + private fun snapshotResponse(): MockResponse { + if (hang.get()) return MockResponse().setSocketPolicy(okhttp3.mockwebserver.SocketPolicy.NO_RESPONSE) + val statusCode = snapshotStatusCode.get() + if (statusCode != HTTP_OK) { + val response = MockResponse().setResponseCode(statusCode) + return if (statusCode == HTTP_NOT_MODIFIED) { + response.setHeader("ETag", rcStrongETag(body.get().toByteArray(Charsets.UTF_8))) + } else { + response + } + } + val bytes = body.get().toByteArray(Charsets.UTF_8) + return MockResponse() + .setResponseCode(200) + .setHeader("ETag", rcStrongETag(bytes)) + .setBody(Buffer().write(bytes)) + .setBodyDelay(responseDelayMillis.get(), TimeUnit.MILLISECONDS) + } + + private companion object { + const val POLL_INTERVAL_MILLIS = 20L + const val ROTATED_RELEASE_NUMBER = 2L + } +} + +internal class ManualScheduler : RemoteConfigFetchScheduler { + private val tasks = mutableListOf() + + /** Every delay the code under test asked for, in scheduling order. */ + val requestedDelays: MutableList = Collections.synchronizedList(mutableListOf()) + + override fun schedule(delayMillis: Long, action: () -> Unit): RemoteConfigFetchScheduledTask { + val task = Task(action) + synchronized(tasks) { tasks += task } + requestedDelays += delayMillis + return RemoteConfigFetchScheduledTask { task.cancelled = true } + } + + /** Fires every scheduled task that has not been cancelled yet. */ + fun runAll() { + val pending = synchronized(tasks) { tasks.toList().also { tasks.clear() } } + pending.forEach { task -> if (!task.cancelled) task.action() } + } + + fun pendingCount(): Int = synchronized(tasks) { tasks.count { !it.cancelled } } + + private class Task(val action: () -> Unit, @Volatile var cancelled: Boolean = false) +} + +internal class InMemorySnapshotStore : RemoteConfigSnapshotStore { + val states = mutableMapOf() + + @Synchronized + override fun load(scope: RemoteConfigSnapshotScope): RemoteConfigSnapshotLoadResult = + states[scope]?.let { RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Found, it) } + ?: RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Missing) + + @Synchronized + override fun save(scope: RemoteConfigSnapshotScope, state: RemoteConfigSnapshotState): Boolean { + states[scope] = state + return true + } +} + +internal class InMemoryFetchPolicyStore : RemoteConfigFetchPolicyStore { + private val states = mutableMapOf() + + @Synchronized + override fun load(scope: RemoteConfigFetchPolicyScope) = states[scope] + + @Synchronized + override fun save(scope: RemoteConfigFetchPolicyScope, state: RemoteConfigFetchPolicyState): Boolean { + states[scope] = state + return true + } +} + +internal class InMemorySessionStore : RemoteConfigSessionStore { + private val sessions = mutableMapOf() + + @Synchronized + override fun load(key: RemoteConfigSessionKey) = sessions[key] + + @Synchronized + override fun save(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession): Boolean { + sessions[key] = session + return true + } + + @Synchronized + override fun clear(key: RemoteConfigSessionKey): Boolean { + sessions.remove(key) + return true + } +} + +/** + * In-memory ack bookkeeping that outlives the harness instance it was handed to, so a "process + * restart" is a new harness over the same map. + */ +internal class InMemoryActivationAckStore : RemoteConfigActivationAckStore { + private val records = mutableMapOf() + + @Synchronized + override fun load(scope: RemoteConfigSnapshotScope): RemoteConfigActivationAckRecord? = records[scope] + + @Synchronized + override fun save(scope: RemoteConfigSnapshotScope, record: RemoteConfigActivationAckRecord): Boolean { + records[scope] = record + return true + } + + @Synchronized + override fun clear(scope: RemoteConfigSnapshotScope): Boolean { + records.remove(scope) + return true + } +} + +/** + * In-memory telemetry buffer that outlives the harness (and the sender) it was handed to, so a + * "process restart" is a new sender over the same map. + */ +internal class InMemoryTelemetryStore : RemoteConfigTelemetryStore { + private val buffers = mutableMapOf>() + private val writes = AtomicInteger() + + /** Set to fail every write, to prove a lost durable write never costs an in-memory event. */ + @Volatile + var failWrites: Boolean = false + + @Volatile + private var loadStarted: CountDownLatch? = null + + @Volatile + private var loadGate: CountDownLatch? = null + + /** + * Every save/clear this store was actually asked to perform. + * + * Counted rather than inferred, because "storage is touched only when the buffer changed shape" + * is a claim about calls, not about content: a write that rewrites the same bytes is still a + * synchronous preferences commit on the worker the config path shares. + */ + val writeCount: Int get() = writes.get() + + /** + * Makes the next [load] announce itself on [started] and park until [gate] opens. + * + * Deliberately NOT synchronized: the whole point is to hold a load open while another thread + * records, which a lock on this object would itself serialise. + */ + fun blockLoadsOn(started: CountDownLatch, gate: CountDownLatch) { + loadStarted = started + loadGate = gate + } + + override fun load(scope: RemoteConfigSnapshotScope): List { + loadStarted?.countDown() + loadGate?.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS) + return synchronized(this) { buffers[scope].orEmpty() } + } + + @Synchronized + override fun save(scope: RemoteConfigSnapshotScope, events: List): Boolean { + writes.incrementAndGet() + if (failWrites) return false + buffers[scope] = events + return true + } + + @Synchronized + override fun clear(scope: RemoteConfigSnapshotScope): Boolean { + writes.incrementAndGet() + if (failWrites) return false + buffers.remove(scope) + return true + } +} + +internal class InMemoryProjectIdStore : RemoteConfigProjectIdStore { + private val projectIds = mutableMapOf, Long>() + + @Synchronized + override fun load(scope: RemoteConfigSnapshotScope): Long? = projectIds[key(scope)] + + @Synchronized + override fun save(scope: RemoteConfigSnapshotScope, projectId: Long): Boolean { + projectIds[key(scope)] = projectId + return true + } + + private fun key(scope: RemoteConfigSnapshotScope) = scope.projectKey to scope.environment +} + +internal class SilentLogger : Logger { + override fun error(message: String) = Unit + override fun warn(message: String) = Unit + override fun release(message: String) = Unit + override fun debug(message: String) = Unit +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/repository/DefaultRepositoryRemoteConfigParsingTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/repository/DefaultRepositoryRemoteConfigParsingTest.kt new file mode 100644 index 000000000..04cacd215 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/repository/DefaultRepositoryRemoteConfigParsingTest.kt @@ -0,0 +1,163 @@ +package com.qonversion.android.sdk.internal.repository + +import com.qonversion.android.sdk.dto.QEnvironment +import com.qonversion.android.sdk.dto.QLaunchMode +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.dto.entitlements.QEntitlementsCacheLifetime +import com.qonversion.android.sdk.internal.EnvironmentProvider +import com.qonversion.android.sdk.internal.IncrementalDelayCalculator +import com.qonversion.android.sdk.internal.InternalConfig +import com.qonversion.android.sdk.internal.api.Api +import com.qonversion.android.sdk.internal.api.ApiErrorMapper +import com.qonversion.android.sdk.internal.api.ApiHelper +import com.qonversion.android.sdk.internal.di.module.NetworkModule +import com.qonversion.android.sdk.internal.dto.config.CacheConfig +import com.qonversion.android.sdk.internal.dto.config.PrimaryConfig +import com.qonversion.android.sdk.internal.logger.Logger +import com.qonversion.android.sdk.listeners.QonversionRemoteConfigCallback +import com.qonversion.android.sdk.listeners.QonversionRemoteConfigListCallback +import io.mockk.mockk +import okhttp3.MediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.util.Random +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +internal class DefaultRepositoryRemoteConfigParsingTest { + @Test + fun `real Moshi JsonDataException maps to response parsing failed`() { + val fixture = repositoryRespondingWith( + """{"payload":"not-an-object","experiment":null,"source":null}""", + ) + try { + val receivedError = AtomicReference() + val success = AtomicReference() + val completed = CountDownLatch(1) + + fixture.repository.remoteConfig("ctx", object : QonversionRemoteConfigCallback { + override fun onSuccess(remoteConfig: com.qonversion.android.sdk.dto.QRemoteConfig) { + success.set(remoteConfig) + completed.countDown() + } + + override fun onError(error: QonversionError) { + receivedError.set(error) + completed.countDown() + } + }) + + assertTrue(completed.await(5, TimeUnit.SECONDS)) + assertNull(success.get()) + assertEquals(QonversionErrorCode.ResponseParsingFailed, receivedError.get()?.code) + } finally { + fixture.close() + } + } + + @Test + fun `list containing any semantically invalid config fails as one response`() { + val fixture = repositoryRespondingWith( + """[ + { + "payload":{"value":"valid"}, + "experiment":null, + "source":{ + "uid":"rc-valid", + "name":"Valid", + "assignment_type":"auto", + "type":"remote_configuration", + "context_key":"valid" + } + }, + {"payload":{"value":"invalid"},"experiment":null,"source":null} + ]""".trimIndent(), + ) + try { + val loads = listOf<(QonversionRemoteConfigListCallback) -> Unit>( + fixture.repository::remoteConfigList, + { callback -> fixture.repository.remoteConfigList(listOf("valid"), false, callback) }, + ) + loads.forEach { load -> + val receivedError = AtomicReference() + val success = AtomicReference() + val completed = CountDownLatch(1) + val callback = object : QonversionRemoteConfigListCallback { + override fun onSuccess(remoteConfigList: QRemoteConfigList) { + success.set(remoteConfigList) + completed.countDown() + } + + override fun onError(error: QonversionError) { + receivedError.set(error) + completed.countDown() + } + } + + load(callback) + + assertTrue(completed.await(5, TimeUnit.SECONDS)) + assertNull(success.get()) + assertEquals(QonversionErrorCode.ResponseParsingFailed, receivedError.get()?.code) + } + } finally { + fixture.close() + } + } + + private fun repositoryRespondingWith(json: String): RepositoryFixture { + val mediaType = MediaType.parse("application/json") + val client = OkHttpClient.Builder() + .addInterceptor { chain -> + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(ResponseBody.create(mediaType, json)) + .build() + } + .build() + val moshi = NetworkModule().provideMoshi() + val api = Retrofit.Builder() + .baseUrl("https://example.test/") + .client(client) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + .create(Api::class.java) + val config = InternalConfig( + PrimaryConfig("project", QLaunchMode.SubscriptionManagement, QEnvironment.Production), + CacheConfig(QEntitlementsCacheLifetime.Month, null), + ).also { it.uid = "user" } + val repository = DefaultRepository( + api = api, + environmentProvider = mockk(relaxed = true), + config = config, + logger = mockk(relaxed = true), + errorMapper = ApiErrorMapper(ApiHelper(config.apiUrl)), + delayCalculator = IncrementalDelayCalculator(Random(0)), + ) + return RepositoryFixture(repository, client) + } + + private data class RepositoryFixture( + val repository: DefaultRepository, + val client: OkHttpClient, + ) { + fun close() { + client.dispatcher().executorService().shutdownNow() + client.connectionPool().evictAll() + } + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaultsReaderTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaultsReaderTest.kt new file mode 100644 index 000000000..84ab5b5bc --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaultsReaderTest.kt @@ -0,0 +1,432 @@ +package com.qonversion.android.sdk.internal.services + +import android.content.Context +import okio.Buffer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.Base64 +import java.util.concurrent.Callable +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +internal class BundledRemoteConfigDefaultsReaderTest { + private val context = io.mockk.mockk(relaxed = true) + + @After + fun resetProcessCache() { + BundledRemoteConfigDefaults.resetForTests() + } + + @Test + fun `server golden parses with exact raw bytes and header`() { + val goldenBytes = SERVER_GOLDEN_ARTIFACT.toByteArray() + assertEquals( + SERVER_GOLDEN_ARTIFACT_SHA256, + MessageDigest.getInstance("SHA-256").digest(goldenBytes).joinToString("") { "%02x".format(it) }, + ) + val document = reader(goldenBytes).read(context) + + assertNotNull(document) + requireNotNull(document) + assertEquals(42L, document.projectId) + assertEquals("env-production", document.environmentUid) + assertEquals("release-portable", document.releaseUid) + assertEquals(7L, document.releaseNumber) + assertEquals(SERVER_GOLDEN_MANIFEST_CONTENT_HASH, document.manifestContentHash) + assertEquals(SERVER_GOLDEN_DEFAULTS_DIGEST, document.defaultsDigest) + assertEquals( + " { \"message\": \"Привет 👋\" } \n".toByteArray().toList(), + requireNotNull(document.defaultFor("alpha")).rawJsonBytes.toList(), + ) + assertEquals("variation-alpha", document.defaultFor("alpha")?.variationUid) + assertEquals("null", String(requireNotNull(document.defaultFor("beta")).rawJsonBytes)) + } + + @Test + fun `all JSON value kinds and Unicode are exposed including wrapped null`() { + val cache = BundledRemoteConfigDefaultsCache(reader(SERVER_ALL_TYPES_ARTIFACT.toByteArray())) + + assertEquals(listOf(1.0, "two", false), cache.value(context, "array")?.rawValue) + assertEquals(true, cache.value(context, "bool")?.rawValue) + val nullValue = cache.value(context, "null") + assertNotNull("a present JSON null must be distinguishable from a missing key", nullValue) + assertNull(nullValue?.rawValue) + assertEquals(42.5, cache.value(context, "number")?.rawValue) + assertEquals( + mapOf("enabled" to true, "nested" to "value"), + cache.value(context, "object")?.rawValue, + ) + assertEquals("hello", cache.value(context, "string")?.rawValue) + assertEquals("Привет 👋", cache.value(context, "unicode")?.rawValue) + assertNull(cache.value(context, "missing")) + } + + @Test + fun `corrupt or noncanonical artifacts fail closed`() { + val valid = String(artifact(listOf(default("alpha", "true"), default("beta", "null")))) + val invalidCases = mapOf( + "digest" to valid.replace( + Regex("\\\"defaultsDigest\\\":\\\"[0-9a-f]{64}\\\""), + "\"defaultsDigest\":\"${"0".repeat(64)}\"", + ).toByteArray(), + "base64" to valid.replace( + Regex("\\\"valueBase64\\\":\\\"[^\\\"]+\\\""), + "\"valueBase64\":\"***\"", + ).toByteArray(), + "json" to artifact(listOf(default("alpha", "not-json"))), + "schema" to artifact(listOf(default("alpha", "true")), schemaVersion = 2), + "manifest hash uppercase" to valid.replace( + SERVER_GOLDEN_MANIFEST_CONTENT_HASH, + SERVER_GOLDEN_MANIFEST_CONTENT_HASH.uppercase(), + ).toByteArray(), + "unsorted" to artifact(listOf(default("beta", "null"), default("alpha", "true"))), + "duplicate" to artifact(listOf(default("alpha", "true"), default("alpha", "false"))), + "missing field" to valid.replace(Regex(",\\\"releaseUid\\\":\\\"[^\\\"]+\\\""), "").toByteArray(), + "unknown field" to valid.replaceFirst("{", "{\"unknown\":true,").toByteArray(), + "duplicate field" to valid.replace( + "{\"schemaVersion\":1,", + "{\"schemaVersion\":1,\"schemaVersion\":1,", + ).toByteArray(), + "noncanonical whitespace" to " $valid".toByteArray(), + "legacy fallback" to "{\"remote_config_list\":[]}".toByteArray(), + ) + + invalidCases.forEach { (name, bytes) -> + assertNull(name, reader(bytes).read(context)) + } + } + + @Test + fun `header identifiers and collection bounds are enforced`() { + val validDefault = default("alpha", "true") + val invalidCases = mapOf( + "zero project id" to artifact(listOf(validDefault), projectId = 0), + "negative project id" to artifact(listOf(validDefault), projectId = -1), + "zero release number" to artifact(listOf(validDefault), releaseNumber = 0), + "negative release number" to artifact(listOf(validDefault), releaseNumber = -1), + "empty environment uid" to artifact(listOf(validDefault), environmentUid = ""), + "long environment uid" to artifact(listOf(validDefault), environmentUid = "e".repeat(37)), + "empty release uid" to artifact(listOf(validDefault), releaseUid = ""), + "long release uid" to artifact(listOf(validDefault), releaseUid = "r".repeat(37)), + "empty key" to artifact(listOf(DefaultFixture("", "variation", "true".toByteArray()))), + "key over 256 UTF-8 bytes" to artifact( + listOf(DefaultFixture("é".repeat(129), "variation", "true".toByteArray())), + ), + "empty variation uid" to artifact( + listOf(DefaultFixture("alpha", "", "true".toByteArray())), + ), + "long variation uid" to artifact( + listOf(DefaultFixture("alpha", "v".repeat(37), "true".toByteArray())), + ), + "more than 1000 defaults" to artifact( + List(1_001) { index -> default("key-${index.toString().padStart(4, '0')}", "true") }, + ), + ) + + invalidCases.forEach { (name, bytes) -> + assertNull(name, reader(bytes).read(context)) + } + + assertNotNull( + "UID limits are measured in Unicode code points", + reader(artifact(listOf(validDefault), environmentUid = "😀".repeat(36))).read(context), + ) + assertNotNull( + "key limit is inclusive and measured in UTF-8 bytes", + reader( + artifact(listOf(DefaultFixture("é".repeat(128), "variation", "true".toByteArray()))), + ).read(context), + ) + } + + @Test + fun `default entries reject missing duplicate unknown and noncanonical fields`() { + val valid = String(artifact(listOf(default("alpha", "true")))) + val invalidCases = mapOf( + "missing variation uid" to valid.replace( + Regex(",\"variationUid\":\"[^\"]+\""), + "", + ), + "duplicate key field" to valid.replace( + "{\"key\":\"alpha\",", + "{\"key\":\"alpha\",\"key\":\"alpha\",", + ), + "unknown default field" to valid.replace( + "{\"key\":\"alpha\",", + "{\"unknown\":true,\"key\":\"alpha\",", + ), + "unpadded base64" to valid.replace("dHJ1ZQ==", "dHJ1ZQ"), + ) + + invalidCases.forEach { (name, artifact) -> + assertNull(name, reader(artifact.toByteArray()).read(context)) + } + } + + @Test + fun `invalid UTF-8 and oversized asset fail closed`() { + val invalidUtf8 = artifact(listOf(default("alpha", "true"))).also { + it[it.indexOf('a'.code.toByte())] = 0x80.toByte() + } + + assertNull(reader(invalidUtf8).read(context)) + assertNull(reader(ByteArray(BUNDLED_REMOTE_CONFIG_DEFAULTS_MAX_BYTES + 1) { ' '.code.toByte() }).read(context)) + } + + @Test + fun `value larger than serving limit and JSON deeper than serving limit fail closed`() { + val tooLarge = "\"${"x".repeat(BUNDLED_REMOTE_CONFIG_DEFAULT_VALUE_MAX_BYTES)}\"" + val tooDeep = "[".repeat(BUNDLED_REMOTE_CONFIG_DEFAULT_JSON_MAX_DEPTH + 1) + + "null" + "]".repeat(BUNDLED_REMOTE_CONFIG_DEFAULT_JSON_MAX_DEPTH + 1) + + assertNull(reader(artifact(listOf(default("alpha", tooLarge)))).read(context)) + assertNull(reader(artifact(listOf(default("alpha", tooDeep)))).read(context)) + } + + @Test + fun `portable number profile accepts shared boundaries and rejects divergence`() { + val finite = BundledRemoteConfigDefaultsCache( + reader(SERVER_NUMBER_BOUNDARIES_ARTIFACT.toByteArray()), + ) + + assertEquals(1e308, finite.value(context, "float_max")?.rawValue) + assertEquals(9_007_199_254_740_991.0, finite.value(context, "int_max")?.rawValue) + assertEquals(-9_007_199_254_740_991.0, finite.value(context, "int_min")?.rawValue) + listOf("1e309", "9007199254740992", "-9007199254740992").forEach { number -> + assertNull(number, reader(artifact(listOf(default("number", number)))).read(context)) + } + assertNull( + "project ID outside the portable exact integer range", + reader(artifact(listOf(default("number", "1")), projectId = 9_007_199_254_740_992L)).read(context), + ) + assertNull( + "release number outside the portable exact integer range", + reader(artifact(listOf(default("number", "1")), releaseNumber = 9_007_199_254_740_992L)).read(context), + ) + } + + @Test + fun `escaped surrogate pairs match the producer portable Unicode profile`() { + val valid = BundledRemoteConfigDefaultsCache( + reader(SERVER_ESCAPED_SURROGATE_ARTIFACT.toByteArray()), + ) + assertEquals("👋", valid.value(context, "escaped_pair")?.rawValue) + + listOf("\"\\ud83d\"", "\"\\ud83d\\u0041\"", "\"\\udc4b\"").forEach { raw -> + assertNull(raw, reader(artifact(listOf(default("unicode", raw)))).read(context)) + } + } + + @Test + fun `process cache loads asset once under concurrency and returns immutable values`() { + val reads = AtomicInteger() + val source = BundledRemoteConfigDefaultsAssetSource { + reads.incrementAndGet() + ByteArrayInputStream( + artifact( + listOf(default("object", "{\"nested\":[{\"value\":1}]}")), + ), + ) + } + val cache = BundledRemoteConfigDefaultsCache(BundledRemoteConfigDefaultsReader(source)) + val executor = Executors.newFixedThreadPool(8) + + val results = executor.invokeAll( + List(64) { Callable { cache.value(context, "object") } }, + ).map { it.get(5, TimeUnit.SECONDS) } + executor.shutdownNow() + + assertEquals(1, reads.get()) + assertTrue(results.all { it?.rawValue == mapOf("nested" to listOf(mapOf("value" to 1.0))) }) + val first = requireNotNull(results.first()).rawValue as Map<*, *> + @Suppress("UNCHECKED_CAST") + val mutable = first as MutableMap + assertThrows(UnsupportedOperationException::class.java) { mutable["mutated"] = true } + assertEquals( + mapOf("nested" to listOf(mapOf("value" to 1.0))), + cache.value(context, "object")?.rawValue, + ) + } + + @Test + fun `raw bytes and digest bytes are defensive copies`() { + val document = requireNotNull( + reader(artifact(listOf(default("alpha", "true")))).read(context), + ) + val entry = requireNotNull(document.defaultFor("alpha")) + + entry.rawJsonBytes[0] = 'f'.code.toByte() + entry.digestBytes()[0] = 'f'.code.toByte() + + assertEquals("true", String(entry.rawJsonBytes)) + assertEquals("true", String(entry.digestBytes())) + } + + @Test + fun `invalid artifact is negatively cached`() { + val reads = AtomicInteger() + val cache = BundledRemoteConfigDefaultsCache( + BundledRemoteConfigDefaultsReader( + BundledRemoteConfigDefaultsAssetSource { + reads.incrementAndGet() + ByteArrayInputStream("not-json".toByteArray()) + }, + ), + ) + + repeat(10) { assertNull(cache.value(context, "any")) } + + assertEquals(1, reads.get()) + } + + private fun reader(bytes: ByteArray) = BundledRemoteConfigDefaultsReader( + BundledRemoteConfigDefaultsAssetSource { ByteArrayInputStream(bytes) }, + ) + + private data class DefaultFixture( + val key: String, + val variationUid: String, + val value: ByteArray, + ) + + private fun default(key: String, rawJson: String) = DefaultFixture( + key = key, + variationUid = "variation-$key", + value = rawJson.toByteArray(), + ) + + private fun artifact( + defaults: List, + schemaVersion: Int = 1, + projectId: Long = 42, + environmentUid: String = "env-production", + releaseUid: String = "release-portable", + releaseNumber: Long = 7, + manifestContentHash: String = SERVER_GOLDEN_MANIFEST_CONTENT_HASH, + ): ByteArray { + val digest = defaultsDigest( + schemaVersion, + projectId, + environmentUid, + releaseUid, + releaseNumber, + manifestContentHash, + defaults, + ) + return buildString { + append("{\"schemaVersion\":").append(schemaVersion) + append(",\"projectId\":").append(projectId) + append(",\"environmentUid\":").append(jsonString(environmentUid)) + append(",\"releaseUid\":").append(jsonString(releaseUid)) + append(",\"releaseNumber\":").append(releaseNumber) + append(",\"manifestContentHash\":\"").append(manifestContentHash).append('"') + append(",\"defaultsDigest\":\"").append(digest).append('"') + append(",\"defaults\":[") + defaults.forEachIndexed { index, value -> + if (index > 0) append(',') + append("{\"key\":").append(jsonString(value.key)) + append(",\"variationUid\":").append(jsonString(value.variationUid)) + append(",\"valueBase64\":\"") + .append(Base64.getEncoder().encodeToString(value.value)) + .append("\"}") + } + append("]}") + }.toByteArray() + } + + private fun defaultsDigest( + schemaVersion: Int, + projectId: Long, + environmentUid: String, + releaseUid: String, + releaseNumber: Long, + manifestContentHash: String, + defaults: List, + ): String { + val digest = MessageDigest.getInstance("SHA-256") + fun part(bytes: ByteArray) { + digest.update(ByteBuffer.allocate(Long.SIZE_BYTES).putLong(bytes.size.toLong()).array()) + digest.update(bytes) + } + fun part(value: String) = part(value.toByteArray(StandardCharsets.UTF_8)) + + part("qonversion.remote-config-fallback-defaults.v1") + part(schemaVersion.toString()) + part(projectId.toString()) + part(environmentUid) + part(releaseUid) + part(releaseNumber.toString()) + part(manifestContentHash) + part(defaults.size.toString()) + defaults.forEach { value -> + part(value.key) + part(value.variationUid) + part(value.value) + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + private fun jsonString(value: String): String { + val buffer = Buffer() + val writer = com.squareup.moshi.JsonWriter.of(buffer) + writer.value(value) + writer.close() + return buffer.readUtf8() + } + + private companion object { + // Replaced only if the server contract golden changes. This is copied + // byte-for-byte from configurator's fallback_artifact_test.go. + const val SERVER_GOLDEN_ARTIFACT = + "{\"schemaVersion\":1,\"projectId\":42,\"environmentUid\":\"env-production\"," + + "\"releaseUid\":\"release-portable\",\"releaseNumber\":7," + + "\"manifestContentHash\":\"0291766e896e3f36aca5385082d74e8bd70fabf12ee776b7dfa2cd4d961c9dea\"," + + "\"defaultsDigest\":\"9e4dfcb4069901c3af4341383c814b24cdc39b20491f7a4593e0036d01f39540\"," + + "\"defaults\":[{\"key\":\"alpha\",\"variationUid\":\"variation-alpha\"," + + "\"valueBase64\":\"IHsgIm1lc3NhZ2UiOiAi0J/RgNC40LLQtdGCIPCfkYsiIH0gCg==\"}," + + "{\"key\":\"beta\",\"variationUid\":\"variation-beta\",\"valueBase64\":\"bnVsbA==\"}]}" + const val SERVER_GOLDEN_MANIFEST_CONTENT_HASH = + "0291766e896e3f36aca5385082d74e8bd70fabf12ee776b7dfa2cd4d961c9dea" + const val SERVER_GOLDEN_DEFAULTS_DIGEST = + "9e4dfcb4069901c3af4341383c814b24cdc39b20491f7a4593e0036d01f39540" + const val SERVER_GOLDEN_ARTIFACT_SHA256 = + "db09d5051c9702773d6dedb4023c8099b20f0cf49daf0471f553c44d4ceaafc5" + const val SERVER_ALL_TYPES_ARTIFACT = + "{\"schemaVersion\":1,\"projectId\":42,\"environmentUid\":\"env-production\"," + + "\"releaseUid\":\"release-all-json-types\",\"releaseNumber\":8," + + "\"manifestContentHash\":\"aa6a89035cd667e719c826029b46d063107991009382955b2332974fea7e417d\"," + + "\"defaultsDigest\":\"05237b0c07eac66a5ae1f7268c9afef3f0170b8649d22559388a4071ee81e95f\"," + + "\"defaults\":[{\"key\":\"array\",\"variationUid\":\"variation-array\",\"valueBase64\":\"WzEsInR3byIsZmFsc2Vd\"}," + + "{\"key\":\"bool\",\"variationUid\":\"variation-bool\",\"valueBase64\":\"dHJ1ZQ==\"}," + + "{\"key\":\"null\",\"variationUid\":\"variation-null\",\"valueBase64\":\"bnVsbA==\"}," + + "{\"key\":\"number\",\"variationUid\":\"variation-number\",\"valueBase64\":\"NDIuNQ==\"}," + + "{\"key\":\"object\",\"variationUid\":\"variation-object\",\"valueBase64\":\"eyJlbmFibGVkIjp0cnVlLCJuZXN0ZWQiOiJ2YWx1ZSJ9\"}," + + "{\"key\":\"string\",\"variationUid\":\"variation-string\",\"valueBase64\":\"ImhlbGxvIg==\"}," + + "{\"key\":\"unicode\",\"variationUid\":\"variation-unicode\",\"valueBase64\":\"ItCf0YDQuNCy0LXRgiDwn5GLIg==\"}]}" + const val SERVER_NUMBER_BOUNDARIES_ARTIFACT = + "{\"schemaVersion\":1,\"projectId\":42,\"environmentUid\":\"env-production\"," + + "\"releaseUid\":\"release-number-boundaries\",\"releaseNumber\":9," + + "\"manifestContentHash\":\"3896effad7acc9270542cb9c9ce76975c65888a19f6efc6eafba299e8e75be4b\"," + + "\"defaultsDigest\":\"0f8138237ed0fc124a161e6dcc2f5fe6e3fa78691b0dea2dbf7f42bc66736d8d\"," + + "\"defaults\":[{\"key\":\"float_max\",\"variationUid\":\"variation-float_max\",\"valueBase64\":\"MWUzMDg=\"}," + + "{\"key\":\"int_max\",\"variationUid\":\"variation-int_max\",\"valueBase64\":\"OTAwNzE5OTI1NDc0MDk5MQ==\"}," + + "{\"key\":\"int_min\",\"variationUid\":\"variation-int_min\",\"valueBase64\":\"LTkwMDcxOTkyNTQ3NDA5OTE=\"}]}" + const val SERVER_ESCAPED_SURROGATE_ARTIFACT = + "{\"schemaVersion\":1,\"projectId\":42,\"environmentUid\":\"env-production\"," + + "\"releaseUid\":\"release-escaped-surrogate\",\"releaseNumber\":10," + + "\"manifestContentHash\":\"e2d2e6cd92b6aca3bae40d4196bcedb6beade7b3dbf672bc13193b3dd9e7ad02\"," + + "\"defaultsDigest\":\"474f2f24b12594d138755a501c2868553a158201ddc955d1c9f5fc7900c8ce44\"," + + "\"defaults\":[{\"key\":\"escaped_pair\",\"variationUid\":\"variation-escaped-pair\"," + + "\"valueBase64\":\"Ilx1ZDgzZFx1ZGM0YiI=\"}]}" + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigCacheTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigCacheTest.kt new file mode 100644 index 000000000..baa35b0e2 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigCacheTest.kt @@ -0,0 +1,1432 @@ +package com.qonversion.android.sdk.internal.storage + +import com.qonversion.android.sdk.dto.QEnvironment +import com.qonversion.android.sdk.dto.QLaunchMode +import com.qonversion.android.sdk.dto.QRemoteConfig +import com.qonversion.android.sdk.dto.QRemoteConfigurationAssignmentType +import com.qonversion.android.sdk.dto.QRemoteConfigurationSource +import com.qonversion.android.sdk.dto.QRemoteConfigurationSourceType +import com.qonversion.android.sdk.dto.entitlements.QEntitlementsCacheLifetime +import com.qonversion.android.sdk.internal.InternalConfig +import com.qonversion.android.sdk.internal.dto.QRemoteConfigurationSourceAssignmentTypeAdapter +import com.qonversion.android.sdk.internal.dto.QRemoteConfigurationSourceTypeAdapter +import com.qonversion.android.sdk.internal.dto.config.CacheConfig +import com.qonversion.android.sdk.internal.dto.config.PrimaryConfig +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.lang.reflect.Type +import java.util.concurrent.Executor +import java.util.concurrent.RejectedExecutionException + +internal class PersistentRemoteConfigCacheTest { + private val backingCache = InMemoryCache() + private val config = internalConfig(projectKey = "project-a", userId = "user-a") + private val moshi = Moshi.Builder() + .add(QRemoteConfigurationSourceTypeAdapter()) + .add(QRemoteConfigurationSourceAssignmentTypeAdapter()) + .build() + private val directExecutor = Executor { it.run() } + + @Test + fun `saved config survives cache recreation`() { + val firstProcess = cache(config) + val expected = remoteConfig(contextKey = "paywall", payloadValue = "v1") + + firstProcess.save(expected) + + val restartedProcess = cache(config) + assertEquals(expected, restartedProcess.get("paywall")) + assertEquals(listOf(expected), restartedProcess.getAll().remoteConfigs) + } + + @Test + fun `empty context last known good is canonical across process restart`() { + val expected = remoteConfig(contextKey = "", payloadValue = "empty-context") + + cache(config).save(expected) + + val restartedProcess = cache(config) + assertEquals(expected, restartedProcess.get(null)) + assertEquals(expected, restartedProcess.get("")) + assertEquals(listOf(expected), restartedProcess.getAll().remoteConfigs) + } + + @Test + fun `payload and bounded index are persisted in one cache transaction`() { + val cache = cache(config) + + cache.save(remoteConfig(contextKey = "paywall", payloadValue = "v1")) + + assertEquals(1, backingCache.batchUpdates.size) + val update = backingCache.batchUpdates.single() + assertEquals(2, update.values.size) + assertTrue(update.values.values.any { it?.contains("\"remoteConfigs\"") == true }) + assertTrue(update.values.values.any { it?.contains("\"scopes\"") == true }) + } + + @Test + fun `cache is isolated by project user and context key`() { + val cache = cache(config) + val expected = remoteConfig(contextKey = "paywall", payloadValue = "user-a") + cache.save(expected) + + assertNull(cache.get("onboarding")) + + config.uid = "user-b" + assertNull(cache.get("paywall")) + + config.uid = "user-a" + val otherProject = internalConfig(projectKey = "project-b", userId = "user-a") + assertNull(cache(otherProject).get("paywall")) + assertEquals(expected, cache.get("paywall")) + } + + @Test + fun `cache is isolated between production and sandbox environments`() { + val productionConfig = internalConfig( + projectKey = "project-a", + userId = "user-a", + environment = QEnvironment.Production, + ) + val sandboxConfig = internalConfig( + projectKey = "project-a", + userId = "user-a", + environment = QEnvironment.Sandbox, + ) + val expected = remoteConfig(contextKey = "paywall", payloadValue = "production") + + cache(productionConfig).save(expected) + + assertNull(cache(sandboxConfig).get("paywall")) + assertEquals(expected, cache(productionConfig).get("paywall")) + } + + @Test + fun `new server value replaces the prior context value`() { + val cache = cache(config) + cache.save(remoteConfig(contextKey = "paywall", payloadValue = "v1")) + + val expected = remoteConfig(contextKey = "paywall", payloadValue = "v2") + cache.save(expected) + + assertEquals(expected, cache.get("paywall")) + assertEquals(listOf(expected), cache.getAll().remoteConfigs) + } + + @Test + fun `remove evicts only the authoritative missing context`() { + val cache = cache(config) + val retained = remoteConfig(contextKey = "onboarding", payloadValue = "retained") + cache.save(remoteConfig(contextKey = "paywall", payloadValue = "stale")) + cache.save(retained) + + cache.remove("paywall") + + assertNull(cache.get("paywall")) + assertEquals(retained, cache.get("onboarding")) + } + + @Test + fun `authoritative replacement evicts configs omitted by the server`() { + val cache = cache(config) + cache.save(remoteConfig(contextKey = "paywall", payloadValue = "stale")) + cache.save(remoteConfig(contextKey = "onboarding", payloadValue = "stale")) + val current = remoteConfig(contextKey = "onboarding", payloadValue = "current") + + cache.replaceAll(listOf(current)) + + assertNull(cache.get("paywall")) + assertEquals(listOf(current), cache.getAll().remoteConfigs) + + cache.replaceAll(emptyList()) + + assertTrue(cache.getAll().remoteConfigs.isEmpty()) + assertTrue(backingCache.strings.isEmpty()) + } + + @Test + fun `filtered reconciliation persists one atomic snapshot visible after restart`() { + val oldFirst = remoteConfig("first", "old-first") + val omittedSecond = remoteConfig("second", "old-second") + val unrelated = remoteConfig("unrelated", "unrelated") + val cache = cache(config) + cache.replaceAll(listOf(oldFirst, omittedSecond, unrelated)) + backingCache.batchUpdates.clear() + val currentFirst = remoteConfig("first", "current-first") + + cache.replaceRequested(setOf("first", "second"), listOf(currentFirst)) + + assertEquals(1, backingCache.batchUpdates.size) + val restarted = cache(config) + assertEquals(listOf(unrelated, currentFirst), restarted.getAll().remoteConfigs) + assertNull(restarted.get("second")) + } + + @Test + fun `invalid config is not persisted`() { + val cache = cache(config) + + cache.save(QRemoteConfig(payload = mapOf("value" to "invalid"), experiment = null, sourceApi = null)) + + assertTrue(backingCache.strings.isEmpty()) + } + + @Test + fun `corrupted cache is ignored and cleared`() { + val cache = cache(config) + cache.save(remoteConfig(contextKey = "paywall", payloadValue = "v1")) + val key = backingCache.strings.entries.single { it.value?.contains("\"remoteConfigs\"") == true }.key + backingCache.strings[key] = "{not-json" + + assertNull(cache(config).get("paywall")) + assertTrue(backingCache.strings.isEmpty()) + } + + @Test + fun `unknown cache version is ignored and cleared`() { + val cache = cache(config) + cache.save(remoteConfig(contextKey = "paywall", payloadValue = "v1")) + val key = backingCache.strings.entries.single { it.value?.contains("\"remoteConfigs\"") == true }.key + backingCache.strings[key] = requireNotNull(backingCache.strings.getValue(key)) + .replace(Regex("\"version\":\\d+"), "\"version\":999") + + assertNull(cache(config).get("paywall")) + assertTrue(backingCache.strings.isEmpty()) + } + + @Test + fun `untrusted index key can never remove an unrelated preference`() { + val unrelatedPreference = "customer_auth_token" + backingCache.putString(unrelatedPreference, "must-survive") + backingCache.putString( + INDEX_KEY, + indexJson(scopeJson(unrelatedPreference, 1)), + ) + val limited = cache( + config, + limits = RemoteConfigCacheLimits(maxScopes = 1, maxEntriesPerScope = 4, maxTotalBytes = 10_000), + ) + + limited.save(remoteConfig("ctx", "current")) + + assertEquals("must-survive", backingCache.getString(unrelatedPreference, null)) + assertFalse(requireNotNull(backingCache.getString(INDEX_KEY, null)).contains(unrelatedPreference)) + } + + @Test + fun `invalid index metadata is discarded without removing referenced keys`() { + val validA = storageKey('a') + val validB = storageKey('b') + val cases = listOf( + "uppercase key" to IndexCase( + scopes = listOf(scopeJson(storageKey('A'), 1)), + limits = RemoteConfigCacheLimits(maxScopes = 1, maxEntriesPerScope = 4, maxTotalBytes = 10_000), + ), + "short key" to IndexCase( + scopes = listOf(scopeJson("qonversion_remote_config_lkg_${"a".repeat(63)}", 1)), + limits = RemoteConfigCacheLimits(maxScopes = 1, maxEntriesPerScope = 4, maxTotalBytes = 10_000), + ), + "zero bytes" to IndexCase( + scopes = listOf(scopeJson(validA, 0)), + limits = RemoteConfigCacheLimits(maxScopes = 1, maxEntriesPerScope = 4, maxTotalBytes = 10_000), + ), + "negative bytes" to IndexCase( + scopes = listOf(scopeJson(validA, -1)), + limits = RemoteConfigCacheLimits(maxScopes = 1, maxEntriesPerScope = 4, maxTotalBytes = 10_000), + ), + "oversized bytes" to IndexCase( + scopes = listOf(scopeJson(validA, 10_001)), + limits = RemoteConfigCacheLimits(maxScopes = 1, maxEntriesPerScope = 4, maxTotalBytes = 10_000), + ), + "duplicate keys" to IndexCase( + scopes = listOf(scopeJson(validA, 1), scopeJson(validA, 1)), + limits = RemoteConfigCacheLimits(maxScopes = 2, maxEntriesPerScope = 4, maxTotalBytes = 10_000), + ), + "too many scopes" to IndexCase( + scopes = listOf(scopeJson(validA, 1), scopeJson(validB, 1)), + limits = RemoteConfigCacheLimits(maxScopes = 1, maxEntriesPerScope = 4, maxTotalBytes = 10_000), + ), + "overflowing total" to IndexCase( + scopes = listOf(scopeJson(validA, Int.MAX_VALUE), scopeJson(validB, Int.MAX_VALUE)), + limits = RemoteConfigCacheLimits( + maxScopes = 8, + maxEntriesPerScope = 4, + maxTotalBytes = Int.MAX_VALUE, + ), + ), + ) + + cases.forEach { (label, case) -> + backingCache.strings.clear() + backingCache.batchUpdates.clear() + val referencedKeys = case.scopes.mapNotNull { scope -> + Regex("\"storageKey\":\"([^\"]+)\"").find(scope)?.groupValues?.get(1) + }.distinct() + referencedKeys.forEach { backingCache.putString(it, "must-survive-$label") } + backingCache.putString(INDEX_KEY, indexJson(*case.scopes.toTypedArray())) + + cache(config, limits = case.limits).save(remoteConfig("ctx-$label", "current")) + + referencedKeys.forEach { referencedKey -> + assertEquals( + label, + "must-survive-$label", + backingCache.getString(referencedKey, null), + ) + } + val rebuiltIndex = requireNotNull(backingCache.getString(INDEX_KEY, null)) + referencedKeys.forEach { referencedKey -> + assertFalse(label, rebuiltIndex.contains(referencedKey)) + } + } + } + + @Test + fun `oversized raw index is rejected before it can name removal targets`() { + val referencedKey = storageKey('c') + backingCache.putString(referencedKey, "must-survive") + backingCache.putString( + INDEX_KEY, + indexJson(scopeJson(referencedKey, 1)) + " ".repeat(70_000), + ) + val limited = cache( + config, + limits = RemoteConfigCacheLimits(maxScopes = 1, maxEntriesPerScope = 4, maxTotalBytes = 10_000), + ) + + limited.save(remoteConfig("ctx", "current")) + + assertEquals("must-survive", backingCache.getString(referencedKey, null)) + val rebuiltIndex = requireNotNull(backingCache.getString(INDEX_KEY, null)) + assertTrue(rebuiltIndex.toByteArray(Charsets.UTF_8).size < 70_000) + assertFalse(rebuiltIndex.contains(referencedKey)) + } + + @Test + fun `under-reported index bytes are discarded after checking the stored payload`() { + cache(config).save(remoteConfig("first", "x".repeat(2_000))) + val firstStorageKey = persistedEnvelopeKey() + val firstPayloadBytes = persistedEnvelopeJson(firstStorageKey).utf8Size() + backingCache.putString(INDEX_KEY, indexJson(scopeJson(firstStorageKey, 1))) + backingCache.putString(UNRELATED_KEY, "must-survive") + config.uid = "user-b" + val restarted = cache( + config, + limits = RemoteConfigCacheLimits( + maxScopes = 2, + maxEntriesPerScope = 4, + maxTotalBytes = firstPayloadBytes, + ), + ) + + restarted.save(remoteConfig("second", "small")) + + val rebuiltIndex = requireNotNull(backingCache.getString(INDEX_KEY, null)) + assertFalse(rebuiltIndex.contains(firstStorageKey)) + assertEquals("must-survive", backingCache.getString(UNRELATED_KEY, null)) + } + + @Test + fun `oversized envelope is rejected after process restart`() { + cache(config).save(remoteConfig("ctx", "x".repeat(2_000))) + val storageKey = persistedEnvelopeKey() + val payloadBytes = persistedEnvelopeJson(storageKey).utf8Size() + backingCache.putString(UNRELATED_KEY, "must-survive") + val restarted = cache( + config, + limits = RemoteConfigCacheLimits( + maxScopes = 2, + maxEntriesPerScope = 4, + maxTotalBytes = payloadBytes - 1, + ), + ) + + assertNull(restarted.get("ctx")) + assertNull(backingCache.getString(storageKey, null)) + assertEquals("must-survive", backingCache.getString(UNRELATED_KEY, null)) + } + + @Test + fun `envelope with too many entries is rejected after process restart`() { + cache(config).replaceAll( + listOf( + remoteConfig("first", "first"), + remoteConfig("second", "second"), + remoteConfig("third", "third"), + ), + ) + val storageKey = persistedEnvelopeKey() + backingCache.putString(UNRELATED_KEY, "must-survive") + val restarted = cache( + config, + limits = RemoteConfigCacheLimits( + maxScopes = 2, + maxEntriesPerScope = 2, + maxTotalBytes = 100_000, + ), + ) + + assertTrue(restarted.getAll().remoteConfigs.isEmpty()) + assertNull(backingCache.getString(storageKey, null)) + assertEquals("must-survive", backingCache.getString(UNRELATED_KEY, null)) + } + + @Test + fun `envelope with duplicate canonical context keys is rejected after process restart`() { + cache(config).save(remoteConfig("seed", "seed")) + val storageKey = persistedEnvelopeKey() + val poisonedJson = envelopeJson( + listOf( + remoteConfig(null, "null-context"), + remoteConfig("", "empty-context"), + ), + ) + backingCache.putString(storageKey, poisonedJson) + backingCache.putString(INDEX_KEY, indexJson(scopeJson(storageKey, poisonedJson.utf8Size()))) + backingCache.putString(UNRELATED_KEY, "must-survive") + + assertNull(cache(config).get(null)) + assertNull(backingCache.getString(storageKey, null)) + assertEquals("must-survive", backingCache.getString(UNRELATED_KEY, null)) + } + + @Test + fun `index entry whose envelope hashes to another scope is discarded`() { + val forgedStorageKey = storageKey('d') + val forgedJson = envelopeJson(listOf(remoteConfig("forged", "forged"))) + backingCache.putString(forgedStorageKey, forgedJson) + backingCache.putString(INDEX_KEY, indexJson(scopeJson(forgedStorageKey, forgedJson.utf8Size()))) + backingCache.putString(UNRELATED_KEY, "must-survive") + + cache(config).save(remoteConfig("current", "current")) + + val rebuiltIndex = requireNotNull(backingCache.getString(INDEX_KEY, null)) + assertFalse(rebuiltIndex.contains(forgedStorageKey)) + assertEquals(forgedJson, backingCache.getString(forgedStorageKey, null)) + assertEquals("must-survive", backingCache.getString(UNRELATED_KEY, null)) + } + + @Test + fun `least recently used identity scope is evicted when scope limit is reached`() { + val limited = cache( + config, + limits = RemoteConfigCacheLimits(maxScopes = 2, maxEntriesPerScope = 10, maxTotalBytes = 100_000), + ) + val first = remoteConfig(contextKey = "paywall", payloadValue = "first") + val second = remoteConfig(contextKey = "paywall", payloadValue = "second") + val third = remoteConfig(contextKey = "paywall", payloadValue = "third") + + config.uid = "user-a" + limited.save(first) + config.uid = "user-b" + limited.save(second) + config.uid = "user-a" + assertEquals(first, limited.get("paywall")) + config.uid = "user-c" + limited.save(third) + + config.uid = "user-b" + assertNull(limited.get("paywall")) + config.uid = "user-a" + assertEquals(first, limited.get("paywall")) + config.uid = "user-c" + assertEquals(third, limited.get("paywall")) + } + + @Test + fun `least recently used context is evicted when entry limit is reached`() { + val limited = cache( + config, + limits = RemoteConfigCacheLimits(maxScopes = 2, maxEntriesPerScope = 2, maxTotalBytes = 100_000), + ) + val first = remoteConfig(contextKey = "first", payloadValue = "first") + val second = remoteConfig(contextKey = "second", payloadValue = "second") + val third = remoteConfig(contextKey = "third", payloadValue = "third") + + limited.save(first) + limited.save(second) + assertEquals(first, limited.get("first")) + limited.save(third) + + assertNull(limited.get("second")) + assertEquals(first, limited.get("first")) + assertEquals(third, limited.get("third")) + } + + @Test + fun `oversized scope is not retained in memory or on disk`() { + val limited = cache( + config, + limits = RemoteConfigCacheLimits(maxScopes = 2, maxEntriesPerScope = 2, maxTotalBytes = 1), + ) + + limited.save(remoteConfig(contextKey = "paywall", payloadValue = "too-large")) + + assertNull(limited.get("paywall")) + assertTrue(backingCache.strings.values.none { it?.contains("too-large") == true }) + } + + @Test + fun `oversized replacement preserves prior valid config for the same context`() { + val previous = remoteConfig(contextKey = "paywall", payloadValue = "small") + cache(config).save(previous) + val previousEnvelopeBytes = requireNotNull( + backingCache.strings.values.single { it?.contains("\"remoteConfigs\"") == true }, + ).toByteArray(Charsets.UTF_8).size + val limited = cache( + config, + limits = RemoteConfigCacheLimits( + maxScopes = 2, + maxEntriesPerScope = 2, + maxTotalBytes = previousEnvelopeBytes + 16, + ), + ) + + limited.save(remoteConfig(contextKey = "paywall", payloadValue = "x".repeat(previousEnvelopeBytes))) + + assertEquals(previous, limited.get("paywall")) + assertEquals(previous, cache(config).get("paywall")) + } + + @Test + fun `byte limiting serializes one bounded newest suffix instead of every dropped prefix`() { + val countingFactory = CountingEnvelopeAdapterFactory() + val countingMoshi = Moshi.Builder() + .add(countingFactory) + .add(QRemoteConfigurationSourceTypeAdapter()) + .add(QRemoteConfigurationSourceAssignmentTypeAdapter()) + .build() + val maxBytes = 16_000 + val limited = PersistentRemoteConfigCache( + cache = backingCache, + config = config, + moshi = countingMoshi, + limits = RemoteConfigCacheLimits( + maxScopes = 2, + maxEntriesPerScope = 64, + maxTotalBytes = maxBytes, + ), + persistenceExecutor = directExecutor, + ) + val configs = List(64) { index -> + remoteConfig("context-$index", "$index-${"x".repeat(2_000)}") + } + + limited.replaceAll(configs) + + assertTrue(countingFactory.envelopeWrites <= 2) + val persisted = limited.getAll().remoteConfigs + assertTrue(persisted.isNotEmpty()) + assertTrue(persisted.size < configs.size) + assertEquals(configs.takeLast(persisted.size), persisted) + val persistedBytes = backingCache.strings.values + .filterNotNull() + .single { it.contains("\"remoteConfigs\"") } + .toByteArray(Charsets.UTF_8) + .size + assertTrue(persistedBytes <= maxBytes) + } + + @Test + fun `invalid authoritative replacement preserves the whole prior last known good set`() { + val paywall = remoteConfig(contextKey = "paywall", payloadValue = "paywall") + val onboarding = remoteConfig(contextKey = "onboarding", payloadValue = "onboarding") + val cache = cache(config) + cache.replaceAll(listOf(paywall, onboarding)) + val invalid = QRemoteConfig(payload = mapOf("value" to "invalid"), experiment = null, sourceApi = null) + + cache.replaceAll(listOf(paywall, invalid)) + + assertEquals(listOf(paywall, onboarding), cache.getAll().remoteConfigs) + } + + @Test + fun `authoritative replacement with duplicate canonical keys preserves prior last known good`() { + val previous = remoteConfig(contextKey = "paywall", payloadValue = "previous") + val cache = cache(config) + cache.replaceAll(listOf(previous)) + + cache.replaceAll( + listOf( + remoteConfig(contextKey = null, payloadValue = "null-context"), + remoteConfig(contextKey = "", payloadValue = "empty-context"), + ), + ) + + assertEquals(listOf(previous), cache.getAll().remoteConfigs) + assertEquals(listOf(previous), cache(config).getAll().remoteConfigs) + } + + @Test + fun `total payload bytes evict least recently used scope`() { + val first = remoteConfig(contextKey = "paywall", payloadValue = "first") + val second = remoteConfig(contextKey = "paywall", payloadValue = "second") + cache(config).save(first) + val oneEnvelopeBytes = requireNotNull( + backingCache.strings.values.single { it?.contains("\"remoteConfigs\"") == true }, + ).toByteArray(Charsets.UTF_8).size + backingCache.strings.clear() + val limited = cache( + config, + limits = RemoteConfigCacheLimits( + maxScopes = 10, + maxEntriesPerScope = 10, + maxTotalBytes = oneEnvelopeBytes + 16, + ), + ) + + config.uid = "user-a" + limited.save(first) + config.uid = "user-b" + limited.save(second) + + val persistedPayloadBytes = backingCache.strings.values + .filterNotNull() + .filter { it.contains("\"remoteConfigs\"") } + .sumOf { it.toByteArray(Charsets.UTF_8).size } + assertTrue(persistedPayloadBytes <= oneEnvelopeBytes + 16) + config.uid = "user-a" + assertNull(limited.get("paywall")) + config.uid = "user-b" + assertEquals(second, limited.get("paywall")) + } + + @Test + fun `durable save completion waits for the off-thread commit`() { + val queuedExecutor = ManualExecutor() + val asyncCache = cache(config, executor = queuedExecutor) + val expected = remoteConfig(contextKey = "paywall", payloadValue = "v1") + var committed: Boolean? = null + + asyncCache.save(requireNotNull(asyncCache.currentScope()), expected) { result -> + committed = result + } + + assertTrue(backingCache.strings.isEmpty()) + assertNull(committed) + assertNull(asyncCache.get("paywall")) + queuedExecutor.runAll() + assertEquals(true, committed) + assertEquals(expected, cache(config).get("paywall")) + } + + @Test + fun `failed durable save preserves the prior committed last known good`() { + val previous = remoteConfig(contextKey = "paywall", payloadValue = "previous") + val persistent = cache(config) + persistent.save(previous) + backingCache.nextDurableUpdateResult = false + var committed: Boolean? = null + + persistent.save( + requireNotNull(persistent.currentScope()), + remoteConfig(contextKey = "paywall", payloadValue = "fresh"), + ) { result -> committed = result } + + assertEquals(false, committed) + assertEquals(previous, persistent.get("paywall")) + assertEquals(previous, cache(config).get("paywall")) + } + + @Test + fun `failed first durable save never exposes the fresh value from memory or disk`() { + val persistent = cache(config) + backingCache.nextDurableUpdateResult = false + var committed: Boolean? = null + + persistent.save( + requireNotNull(persistent.currentScope()), + remoteConfig(contextKey = "paywall", payloadValue = "fresh"), + ) { result -> committed = result } + + assertEquals(false, committed) + assertNull(persistent.get("paywall")) + assertNull(cache(config).get("paywall")) + assertTrue(backingCache.strings.isEmpty()) + } + + @Test + fun `exception during durable save preserves the prior committed last known good`() { + val previous = remoteConfig(contextKey = "paywall", payloadValue = "previous") + val persistent = cache(config) + persistent.save(previous) + backingCache.throwOnNextDurableUpdate = true + var committed: Boolean? = null + + persistent.save( + requireNotNull(persistent.currentScope()), + remoteConfig(contextKey = "paywall", payloadValue = "fresh"), + ) { result -> committed = result } + + assertEquals(false, committed) + assertEquals(previous, persistent.get("paywall")) + assertEquals(previous, cache(config).get("paywall")) + } + + @Test + fun `rejected persistence scheduling fails completion without replacing prior LKG`() { + val previous = remoteConfig(contextKey = "paywall", payloadValue = "previous") + cache(config).save(previous) + val rejectingCache = cache( + config, + executor = Executor { throw RejectedExecutionException("shutting down") }, + ) + var committed: Boolean? = null + + rejectingCache.save( + requireNotNull(rejectingCache.currentScope()), + remoteConfig(contextKey = "paywall", payloadValue = "fresh"), + ) { result -> committed = result } + + assertEquals(false, committed) + assertEquals(previous, rejectingCache.get("paywall")) + assertEquals(previous, cache(config).get("paywall")) + } + + @Test + fun `failed durable removal preserves payload and index for restart`() { + val previous = remoteConfig(contextKey = "paywall", payloadValue = "previous") + val persistent = cache(config) + persistent.save(previous) + val priorStrings = backingCache.strings.toMap() + backingCache.nextDurableUpdateResult = false + var committed: Boolean? = null + + persistent.remove(requireNotNull(persistent.currentScope()), "paywall") { result -> + committed = result + } + + assertEquals(false, committed) + assertEquals(priorStrings, backingCache.strings) + assertEquals(previous, persistent.get("paywall")) + assertEquals(previous, cache(config).get("paywall")) + } + + @Test + fun `failed durable list reconciliation preserves the whole prior snapshot`() { + val first = remoteConfig(contextKey = "first", payloadValue = "old-first") + val second = remoteConfig(contextKey = "second", payloadValue = "old-second") + val persistent = cache(config) + persistent.replaceAll(listOf(first, second)) + val priorStrings = backingCache.strings.toMap() + backingCache.nextDurableUpdateResult = false + var committed: Boolean? = null + + persistent.replaceRequested( + requireNotNull(persistent.currentScope()), + requestedContextKeys = setOf("first", "second"), + remoteConfigs = listOf(remoteConfig("first", "fresh-first")), + ) { result -> committed = result } + + assertEquals(false, committed) + assertEquals(priorStrings, backingCache.strings) + assertEquals(listOf(first, second), persistent.getAll().remoteConfigs) + assertEquals(listOf(first, second), cache(config).getAll().remoteConfigs) + } + + @Test + fun `coalesced durable saves succeed when the latest snapshot contains both requested values`() { + val queuedExecutor = ManualExecutor() + val persistent = cache(config, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val first = remoteConfig(contextKey = "first", payloadValue = "first") + val second = remoteConfig(contextKey = "second", payloadValue = "second") + val completions = mutableListOf>() + + persistent.save(scope, first) { completions += "first" to it } + persistent.save(scope, second) { completions += "second" to it } + + assertTrue(completions.isEmpty()) + queuedExecutor.runNext() + assertTrue(completions.isEmpty()) + queuedExecutor.runNext() + + assertEquals(listOf("first" to true, "second" to true), completions) + assertEquals(listOf(first, second), cache(config).getAll().remoteConfigs) + } + + @Test + fun `coalesced durable save reports a superseded value for the same context as uncommitted`() { + val queuedExecutor = ManualExecutor() + val persistent = cache(config, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val first = remoteConfig(contextKey = "shared", payloadValue = "first") + val second = remoteConfig(contextKey = "shared", payloadValue = "second") + val completions = mutableListOf>() + + persistent.save(scope, first) { completions += "first" to it } + persistent.save(scope, second) { completions += "second" to it } + + queuedExecutor.runAll() + + assertEquals(listOf("first" to false, "second" to true), completions) + assertEquals(listOf(second), cache(config).getAll().remoteConfigs) + } + + @Test + fun `coalesced removal and unrelated save both succeed when the target stays absent`() { + val target = remoteConfig(contextKey = "target", payloadValue = "old") + cache(config).save(target) + val queuedExecutor = ManualExecutor() + val persistent = cache(config, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val unrelated = remoteConfig(contextKey = "unrelated", payloadValue = "fresh") + val completions = mutableListOf>() + + persistent.remove(scope, "target") { completions += "remove" to it } + persistent.save(scope, unrelated) { completions += "save" to it } + queuedExecutor.runAll() + + assertEquals(listOf("remove" to true, "save" to true), completions) + assertEquals(listOf(unrelated), cache(config).getAll().remoteConfigs) + } + + @Test + fun `coalesced removal fails when a later save re-adds the same target`() { + cache(config).save(remoteConfig(contextKey = "target", payloadValue = "old")) + val queuedExecutor = ManualExecutor() + val persistent = cache(config, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val replacement = remoteConfig(contextKey = "target", payloadValue = "fresh") + val completions = mutableListOf>() + + persistent.remove(scope, "target") { completions += "remove" to it } + persistent.save(scope, replacement) { completions += "save" to it } + queuedExecutor.runAll() + + assertEquals(listOf("remove" to false, "save" to true), completions) + assertEquals(listOf(replacement), cache(config).getAll().remoteConfigs) + } + + @Test + fun `coalesced requested replacement and unrelated save both succeed`() { + val oldRequested = remoteConfig(contextKey = "requested", payloadValue = "old") + cache(config).save(oldRequested) + val queuedExecutor = ManualExecutor() + val persistent = cache(config, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val replacement = remoteConfig(contextKey = "requested", payloadValue = "fresh") + val unrelated = remoteConfig(contextKey = "unrelated", payloadValue = "fresh") + val completions = mutableListOf>() + + persistent.replaceRequested(scope, setOf("requested"), listOf(replacement)) { + completions += "replace" to it + } + persistent.save(scope, unrelated) { completions += "save" to it } + queuedExecutor.runAll() + + assertEquals(listOf("replace" to true, "save" to true), completions) + assertEquals(listOf(replacement, unrelated), cache(config).getAll().remoteConfigs) + } + + @Test + fun `coalesced requested replacement fails when a later save overwrites its target`() { + val queuedExecutor = ManualExecutor() + val persistent = cache(config, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val replacement = remoteConfig(contextKey = "requested", payloadValue = "first") + val overwrite = remoteConfig(contextKey = "requested", payloadValue = "second") + val completions = mutableListOf>() + + persistent.replaceRequested(scope, setOf("requested"), listOf(replacement)) { + completions += "replace" to it + } + persistent.save(scope, overwrite) { completions += "save" to it } + queuedExecutor.runAll() + + assertEquals(listOf("replace" to false, "save" to true), completions) + assertEquals(listOf(overwrite), cache(config).getAll().remoteConfigs) + } + + @Test + fun `coalesced requested omission fails when a later save re-adds the omitted target`() { + cache(config).save(remoteConfig(contextKey = "requested", payloadValue = "old")) + val queuedExecutor = ManualExecutor() + val persistent = cache(config, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val readded = remoteConfig(contextKey = "requested", payloadValue = "fresh") + val completions = mutableListOf>() + + persistent.replaceRequested(scope, setOf("requested"), emptyList()) { + completions += "replace" to it + } + persistent.save(scope, readded) { completions += "save" to it } + queuedExecutor.runAll() + + assertEquals(listOf("replace" to false, "save" to true), completions) + assertEquals(listOf(readded), cache(config).getAll().remoteConfigs) + } + + @Test + fun `coalesced requested omission and unrelated save both succeed while the omitted target stays absent`() { + cache(config).save(remoteConfig(contextKey = "requested", payloadValue = "old")) + val queuedExecutor = ManualExecutor() + val persistent = cache(config, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val unrelated = remoteConfig(contextKey = "unrelated", payloadValue = "fresh") + val completions = mutableListOf>() + + persistent.replaceRequested(scope, setOf("requested"), emptyList()) { + completions += "replace" to it + } + persistent.save(scope, unrelated) { completions += "save" to it } + queuedExecutor.runAll() + + assertEquals(listOf("replace" to true, "save" to true), completions) + assertEquals(listOf(unrelated), cache(config).getAll().remoteConfigs) + } + + @Test + fun `coalesced replace all reports false when a later mutation changes its whole snapshot`() { + val queuedExecutor = ManualExecutor() + val persistent = cache(config, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val authoritative = remoteConfig(contextKey = "requested", payloadValue = "fresh") + val later = remoteConfig(contextKey = "unrelated", payloadValue = "later") + val completions = mutableListOf>() + + persistent.replaceAll(scope, listOf(authoritative)) { completions += "replaceAll" to it } + persistent.save(scope, later) { completions += "save" to it } + queuedExecutor.runAll() + + assertEquals(listOf("replaceAll" to false, "save" to true), completions) + assertEquals(listOf(authoritative, later), cache(config).getAll().remoteConfigs) + } + + @Test + fun `oversized newer single save fails only itself and does not cancel an accepted pending save`() { + val fitting = remoteConfig(contextKey = "fitting", payloadValue = "small") + cache(config).save(fitting) + val oneEnvelopeBytes = persistedEnvelopeJson(persistedEnvelopeKey()).utf8Size() + backingCache.strings.clear() + backingCache.batchUpdates.clear() + val queuedExecutor = ManualExecutor() + val persistent = cache( + config, + limits = RemoteConfigCacheLimits(2, 4, oneEnvelopeBytes + 16), + executor = queuedExecutor, + ) + val oversized = remoteConfig("oversized", "x".repeat(oneEnvelopeBytes)) + val completions = mutableListOf>() + + persistent.save(requireNotNull(persistent.currentScope()), fitting) { + completions += "fitting" to it + } + persistent.save(requireNotNull(persistent.currentScope()), oversized) { + completions += "oversized" to it + } + + assertEquals(listOf("oversized" to false), completions) + queuedExecutor.runAll() + assertEquals(listOf("oversized" to false, "fitting" to true), completions) + assertEquals(listOf(fitting), cache(config).getAll().remoteConfigs) + } + + @Test + fun `oversized newer strict replacement fails only itself and preserves an accepted pending replacement`() { + val fitting = remoteConfig(contextKey = "fitting", payloadValue = "small") + cache(config).save(fitting) + val oneEnvelopeBytes = persistedEnvelopeJson(persistedEnvelopeKey()).utf8Size() + backingCache.strings.clear() + backingCache.batchUpdates.clear() + val queuedExecutor = ManualExecutor() + val persistent = cache( + config, + limits = RemoteConfigCacheLimits(2, 4, oneEnvelopeBytes + 16), + executor = queuedExecutor, + ) + val oversized = remoteConfig("oversized", "x".repeat(oneEnvelopeBytes)) + val completions = mutableListOf>() + val scope = requireNotNull(persistent.currentScope()) + + persistent.replaceAll(scope, listOf(fitting)) { completions += "fitting" to it } + persistent.replaceAll(scope, listOf(oversized)) { completions += "oversized" to it } + + assertEquals(listOf("oversized" to false), completions) + queuedExecutor.runAll() + assertEquals(listOf("oversized" to false, "fitting" to true), completions) + assertEquals(listOf(fitting), cache(config).getAll().remoteConfigs) + } + + @Test + fun `rejected newer save restores an accepted pending save and completes each exactly once`() { + val executor = AcceptFirstRejectSecondExecutor() + val persistent = cache(config, executor = executor) + val scope = requireNotNull(persistent.currentScope()) + val first = remoteConfig(contextKey = "first", payloadValue = "first") + val second = remoteConfig(contextKey = "second", payloadValue = "second") + val completions = mutableListOf>() + + persistent.save(scope, first) { completions += "first" to it } + persistent.save(scope, second) { completions += "second" to it } + + assertEquals(listOf("second" to false), completions) + executor.runAccepted() + assertEquals(listOf("second" to false, "first" to true), completions) + assertEquals(listOf(first), cache(config).getAll().remoteConfigs) + } + + @Test + fun `executor that runs then rejects cannot complete the same durable save twice`() { + val persistent = cache(config, executor = RunThenRejectExecutor()) + val expected = remoteConfig(contextKey = "context", payloadValue = "value") + val completions = mutableListOf() + + persistent.save(requireNotNull(persistent.currentScope()), expected) { + completions += it + } + + assertEquals(listOf(true), completions) + assertEquals(expected, cache(config).get("context")) + } + + @Test + fun `strict durable reconciliation never commits a bounded partial snapshot`() { + val second = remoteConfig(contextKey = "second", payloadValue = "second-${"x".repeat(1_000)}") + cache(config).save(second) + val oneEnvelopeBytes = persistedEnvelopeJson(persistedEnvelopeKey()).utf8Size() + backingCache.strings.clear() + backingCache.batchUpdates.clear() + val strictCache = cache( + config, + limits = RemoteConfigCacheLimits( + maxScopes = 2, + maxEntriesPerScope = 4, + maxTotalBytes = oneEnvelopeBytes + 16, + ), + ) + val first = remoteConfig(contextKey = "first", payloadValue = "first-${"x".repeat(1_000)}") + var committed: Boolean? = null + + strictCache.replaceAll( + requireNotNull(strictCache.currentScope()), + listOf(first, second), + ) { result -> committed = result } + + assertEquals(false, committed) + assertTrue(backingCache.strings.isEmpty()) + assertTrue(strictCache.getAll().remoteConfigs.isEmpty()) + } + + @Test + fun `durable single save may evict old contexts when the requested value is fully committed`() { + val old = remoteConfig(contextKey = "old", payloadValue = "old-${"x".repeat(1_000)}") + cache(config).save(old) + val oneEnvelopeBytes = persistedEnvelopeJson(persistedEnvelopeKey()).utf8Size() + backingCache.strings.clear() + backingCache.batchUpdates.clear() + val boundedCache = cache( + config, + limits = RemoteConfigCacheLimits( + maxScopes = 2, + maxEntriesPerScope = 4, + maxTotalBytes = oneEnvelopeBytes + 16, + ), + ) + boundedCache.save(old) + val fresh = remoteConfig(contextKey = "fresh", payloadValue = "fresh-${"x".repeat(1_000)}") + var committed: Boolean? = null + + boundedCache.save(requireNotNull(boundedCache.currentScope()), fresh) { result -> + committed = result + } + + assertEquals(true, committed) + assertEquals(listOf(fresh), boundedCache.getAll().remoteConfigs) + assertEquals(listOf(fresh), cache(config).getAll().remoteConfigs) + } + + @Test + fun `strict operation after a bounded pending save builds from the admitted snapshot`() { + val oldFirst = remoteConfig(contextKey = "old-first", payloadValue = "first-${"x".repeat(1_000)}") + val oldSecond = remoteConfig(contextKey = "old-second", payloadValue = "second-${"x".repeat(1_000)}") + cache(config).replaceAll(listOf(oldFirst, oldSecond)) + val twoEnvelopeBytes = persistedEnvelopeJson(persistedEnvelopeKey()).utf8Size() + backingCache.strings.clear() + backingCache.batchUpdates.clear() + val limits = RemoteConfigCacheLimits( + maxScopes = 2, + maxEntriesPerScope = 4, + maxTotalBytes = twoEnvelopeBytes + 16, + ) + cache(config, limits = limits).replaceAll(listOf(oldFirst, oldSecond)) + val queuedExecutor = ManualExecutor() + val persistent = cache(config, limits = limits, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val added = remoteConfig(contextKey = "added", payloadValue = "added-${"x".repeat(1_000)}") + val updatedSecond = remoteConfig( + contextKey = "old-second", + payloadValue = "updated-${"x".repeat(1_000)}", + ) + val completions = mutableListOf>() + + persistent.save(scope, added) { completions += "save" to it } + persistent.replaceRequested(scope, setOf("old-second"), listOf(updatedSecond)) { + completions += "replace" to it + } + + assertTrue(completions.isEmpty()) + queuedExecutor.runAll() + assertEquals(listOf("save" to true, "replace" to true), completions) + assertEquals(listOf(added, updatedSecond), cache(config).getAll().remoteConfigs) + } + + @Test + fun `rejection restores the admitted bounded snapshot for the next strict operation`() { + val oldFirst = remoteConfig(contextKey = "old-first", payloadValue = "first-${"x".repeat(1_000)}") + val oldSecond = remoteConfig(contextKey = "old-second", payloadValue = "second-${"x".repeat(1_000)}") + cache(config).replaceAll(listOf(oldFirst, oldSecond)) + val twoEnvelopeBytes = persistedEnvelopeJson(persistedEnvelopeKey()).utf8Size() + backingCache.strings.clear() + backingCache.batchUpdates.clear() + val limits = RemoteConfigCacheLimits(2, 4, twoEnvelopeBytes + 16) + cache(config, limits = limits).replaceAll(listOf(oldFirst, oldSecond)) + val executor = ScriptedExecutor(acceptance = listOf(true, false, true)) + val persistent = cache(config, limits = limits, executor = executor) + val scope = requireNotNull(persistent.currentScope()) + val added = remoteConfig(contextKey = "added", payloadValue = "added-${"x".repeat(1_000)}") + val rejected = remoteConfig( + contextKey = "rejected", + payloadValue = "rejected-${"x".repeat(1_000)}", + ) + val updatedSecond = remoteConfig( + contextKey = "old-second", + payloadValue = "updated-${"x".repeat(1_000)}", + ) + val completions = mutableListOf>() + + persistent.save(scope, added) { completions += "save" to it } + persistent.save(scope, rejected) { completions += "rejected" to it } + persistent.replaceRequested(scope, setOf("old-second"), listOf(updatedSecond)) { + completions += "replace" to it + } + + assertEquals(listOf("rejected" to false), completions) + executor.runAccepted() + assertEquals( + listOf("rejected" to false, "save" to true, "replace" to true), + completions, + ) + assertEquals(listOf(added, updatedSecond), cache(config).getAll().remoteConfigs) + } + + @Test + fun `durable single save rejects an individually oversized requested value without changing storage`() { + val previous = remoteConfig(contextKey = "old", payloadValue = "small") + cache(config).save(previous) + val priorEnvelopeBytes = persistedEnvelopeJson(persistedEnvelopeKey()).utf8Size() + val priorStrings = backingCache.strings.toMap() + val boundedCache = cache( + config, + limits = RemoteConfigCacheLimits( + maxScopes = 2, + maxEntriesPerScope = 4, + maxTotalBytes = priorEnvelopeBytes + 16, + ), + ) + val oversized = remoteConfig( + contextKey = "fresh", + payloadValue = "oversized-${"x".repeat(priorEnvelopeBytes)}", + ) + var committed: Boolean? = null + + boundedCache.save(requireNotNull(boundedCache.currentScope()), oversized) { result -> + committed = result + } + + assertEquals(false, committed) + assertEquals(priorStrings, backingCache.strings) + assertEquals(listOf(previous), boundedCache.getAll().remoteConfigs) + assertEquals(listOf(previous), cache(config).getAll().remoteConfigs) + } + + @Test + fun `strict durable replace all rejects entry truncation without changing storage`() { + val strictCache = cache( + config, + limits = RemoteConfigCacheLimits( + maxScopes = 2, + maxEntriesPerScope = 1, + maxTotalBytes = 10_000, + ), + ) + var committed: Boolean? = null + + strictCache.replaceAll( + requireNotNull(strictCache.currentScope()), + listOf(remoteConfig("first", "first"), remoteConfig("second", "second")), + ) { result -> committed = result } + + assertEquals(false, committed) + assertTrue(backingCache.strings.isEmpty()) + assertTrue(strictCache.getAll().remoteConfigs.isEmpty()) + } + + @Test + fun `strict durable requested reconciliation rejects entry truncation and preserves prior snapshot`() { + val first = remoteConfig("first", "first") + val strictCache = cache( + config, + limits = RemoteConfigCacheLimits( + maxScopes = 2, + maxEntriesPerScope = 1, + maxTotalBytes = 10_000, + ), + ) + strictCache.save(first) + val priorStrings = backingCache.strings.toMap() + var committed: Boolean? = null + + strictCache.replaceRequested( + requireNotNull(strictCache.currentScope()), + requestedContextKeys = setOf("second"), + remoteConfigs = listOf(remoteConfig("second", "second")), + ) { result -> committed = result } + + assertEquals(false, committed) + assertEquals(priorStrings, backingCache.strings) + assertEquals(listOf(first), strictCache.getAll().remoteConfigs) + } + + @Test + fun `failed scope eviction keeps payload and index on the prior atomic snapshot`() { + val limited = cache( + config, + limits = RemoteConfigCacheLimits(maxScopes = 1, maxEntriesPerScope = 4, maxTotalBytes = 10_000), + ) + val first = remoteConfig(contextKey = "ctx", payloadValue = "first") + limited.save(first) + val priorStrings = backingCache.strings.toMap() + backingCache.nextDurableUpdateResult = false + config.uid = "user-b" + var committed: Boolean? = null + + limited.save( + requireNotNull(limited.currentScope()), + remoteConfig(contextKey = "ctx", payloadValue = "second"), + ) { result -> committed = result } + + assertEquals(false, committed) + assertEquals(priorStrings, backingCache.strings) + config.uid = "user-a" + assertEquals(first, limited.get("ctx")) + config.uid = "user-b" + assertNull(limited.get("ctx")) + } + + private fun cache( + internalConfig: InternalConfig, + limits: RemoteConfigCacheLimits = RemoteConfigCacheLimits(), + executor: Executor = directExecutor, + ) = PersistentRemoteConfigCache( + cache = backingCache, + config = internalConfig, + moshi = moshi, + limits = limits, + persistenceExecutor = executor, + ) + + private fun internalConfig( + projectKey: String, + userId: String, + environment: QEnvironment = QEnvironment.Production, + ) = InternalConfig( + primaryConfig = PrimaryConfig( + projectKey = projectKey, + launchMode = QLaunchMode.SubscriptionManagement, + environment = environment, + ), + cacheConfig = CacheConfig(QEntitlementsCacheLifetime.Month, null), + ).also { it.uid = userId } + + private fun remoteConfig(contextKey: String?, payloadValue: String) = QRemoteConfig( + payload = mapOf("value" to payloadValue), + experiment = null, + sourceApi = QRemoteConfigurationSource( + id = "remote-config-id", + name = "Remote Config", + assignmentType = QRemoteConfigurationAssignmentType.Auto, + type = QRemoteConfigurationSourceType.RemoteConfiguration, + contextKeyApi = contextKey, + ), + ) + + private fun persistedEnvelopeKey() = backingCache.strings.entries + .single { it.value?.contains("\"remoteConfigs\"") == true } + .key + + private fun persistedEnvelopeJson(storageKey: String) = + requireNotNull(backingCache.getString(storageKey, null)) + + private fun envelopeJson(remoteConfigs: List) = moshi + .adapter(PersistentRemoteConfigEnvelope::class.java) + .toJson( + PersistentRemoteConfigEnvelope( + version = 2, + projectKey = config.primaryConfig.projectKey, + environment = config.environment.name, + userId = config.uid, + remoteConfigs = remoteConfigs, + ), + ) + + private fun String.utf8Size() = toByteArray(Charsets.UTF_8).size + + private fun storageKey(hex: Char) = "qonversion_remote_config_lkg_${hex.toString().repeat(64)}" + + private fun scopeJson(storageKey: String, bytes: Int) = + "{\"storageKey\":\"$storageKey\",\"bytes\":$bytes}" + + private fun indexJson(vararg scopes: String) = + "{\"version\":1,\"scopes\":[${scopes.joinToString(",")}] }" + + private data class IndexCase( + val scopes: List, + val limits: RemoteConfigCacheLimits, + ) + + private class InMemoryCache : Cache { + data class BatchUpdate( + val values: Map, + val removedKeys: Set, + ) + + val strings = mutableMapOf() + val batchUpdates = mutableListOf() + var nextDurableUpdateResult = true + var throwOnNextDurableUpdate = false + private val values = mutableMapOf() + + override fun putInt(key: String, value: Int) { values[key] = value } + override fun getInt(key: String, defValue: Int) = values[key] as? Int ?: defValue + override fun getBool(key: String, defValue: Boolean) = values[key] as? Boolean ?: defValue + override fun putBool(key: String, value: Boolean) { values[key] = value } + override fun putFloat(key: String, value: Float) { values[key] = value } + override fun getFloat(key: String, defValue: Float) = values[key] as? Float ?: defValue + override fun putLong(key: String, value: Long) { values[key] = value } + override fun getLong(key: String, defValue: Long) = values[key] as? Long ?: defValue + override fun putString(key: String, value: String?) { strings[key] = value } + override fun updateStrings(values: Map, removedKeys: Set) { + batchUpdates += BatchUpdate(values.toMap(), removedKeys.toSet()) + removedKeys.forEach(strings::remove) + strings.putAll(values) + } + override fun updateStringsDurably(values: Map, removedKeys: Set): Boolean { + if (throwOnNextDurableUpdate) { + throwOnNextDurableUpdate = false + throw IllegalStateException("simulated storage failure") + } + val result = nextDurableUpdateResult + nextDurableUpdateResult = true + if (result) updateStrings(values, removedKeys) + return result + } + override fun getString(key: String, defValue: String?) = strings[key] ?: defValue + override fun putObject(key: String, value: T, adapter: JsonAdapter) { + putString(key, adapter.toJson(value)) + } + override fun getObject(key: String, adapter: JsonAdapter): T? = + getString(key, null)?.let(adapter::fromJson) + override fun remove(key: String) { + strings.remove(key) + values.remove(key) + } + } + + private class ManualExecutor : Executor { + private val tasks = ArrayDeque() + + override fun execute(command: Runnable) { + tasks.addLast(command) + } + + fun runAll() { + while (tasks.isNotEmpty()) { + tasks.removeFirst().run() + } + } + + fun runNext() { + tasks.removeFirst().run() + } + } + + private class AcceptFirstRejectSecondExecutor : Executor { + private var accepted: Runnable? = null + private var invocationCount = 0 + + override fun execute(command: Runnable) { + invocationCount += 1 + if (invocationCount == 1) { + accepted = command + } else { + throw RejectedExecutionException("simulated rejection") + } + } + + fun runAccepted() { + requireNotNull(accepted).run() + accepted = null + } + } + + private class RunThenRejectExecutor : Executor { + override fun execute(command: Runnable) { + command.run() + throw RejectedExecutionException("simulated rejection after execution") + } + } + + private class ScriptedExecutor(private val acceptance: List) : Executor { + private val accepted = ArrayDeque() + private var invocationCount = 0 + + override fun execute(command: Runnable) { + val accepts = acceptance.getOrElse(invocationCount) { false } + invocationCount += 1 + if (accepts) { + accepted.addLast(command) + } else { + throw RejectedExecutionException("simulated rejection") + } + } + + fun runAccepted() { + while (accepted.isNotEmpty()) accepted.removeFirst().run() + } + } + + private class CountingEnvelopeAdapterFactory : JsonAdapter.Factory { + var envelopeWrites = 0 + + override fun create( + type: Type, + annotations: Set, + moshi: Moshi, + ): JsonAdapter<*>? { + if (Types.getRawType(type) != PersistentRemoteConfigEnvelope::class.java) return null + val delegate = moshi.nextAdapter(this, type, annotations) + return object : JsonAdapter() { + override fun fromJson(reader: JsonReader): Any? = delegate.fromJson(reader) + + override fun toJson(writer: JsonWriter, value: Any?) { + envelopeWrites += 1 + delegate.toJson(writer, value) + } + } + } + } + + private companion object { + const val INDEX_KEY = "qonversion_remote_config_lkg_index" + const val UNRELATED_KEY = "customer_auth_token" + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStoreTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStoreTest.kt new file mode 100644 index 000000000..fa0b72055 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStoreTest.kt @@ -0,0 +1,754 @@ +package com.qonversion.android.sdk.internal.storage + +import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshotApplyPolicy +import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshotEnvelopeExpectation +import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshotEnvelopeParser +import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshotCore +import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshotEntry +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.JsonAdapter +import com.squareup.moshi.Moshi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class PersistentRemoteConfigSnapshotStoreTest { + private val cache = SnapshotInMemoryCache() + private val moshi = Moshi.Builder().build() + private val userA = RemoteConfigSnapshotScope("project", "production", "canonical-user-a") + private val userB = RemoteConfigSnapshotScope("project", "production", "canonical-user-b") + + @Test + fun `candidate active and previous persist as one exact scoped versioned state`() { + val state = RemoteConfigSnapshotState( + candidate = release("three", 3, "candidate"), + active = release("two", 2, "active"), + previous = release("one", 1, "previous"), + didActivate = true, + ) + + assertTrue(store().save(userA, state)) + + assertEquals(1, cache.durableUpdates.size) + assertEquals(2, cache.durableUpdates.single().values.size) + assertTrue(cache.durableUpdates.single().values.containsKey(REMOTE_CONFIG_SNAPSHOT_INDEX_KEY)) + assertTrue(cache.durableUpdates.single().values.containsKey(remoteConfigSnapshotStorageKey(userA))) + val restarted = store().loadState(userA) + assertEquals("three", restarted?.candidate?.releaseUid) + assertEquals("two", restarted?.active?.releaseUid) + assertEquals("one", restarted?.previous?.releaseUid) + assertTrue(restarted?.didActivate == true) + } + + @Test + fun `candidate strong ETag and exact canonical body survive durable restart without transport aliases`() { + val body = wireBody() + val release = wireRelease(body, admissionToken = 5) + val etag = requireNotNull(release.strongETag) + val state = RemoteConfigSnapshotState( + candidate = release, + active = release, + didActivate = true, + ) + + assertTrue(store().save(userA, state)) + body.fill('x'.code.toByte()) + + val restarted = store().loadState(userA) + assertArrayEquals(wireBody(), restarted?.candidate?.canonicalBodyBytes) + assertArrayEquals(wireBody(), restarted?.active?.canonicalBodyBytes) + assertEquals(etag, restarted?.candidate?.strongETag) + assertEquals(etag, restarted?.active?.strongETag) + assertEquals("b".repeat(64), restarted?.candidate?.contextFingerprint) + assertEquals(5L, restarted?.latestAdmissionToken) + } + + @Test + fun `persisted candidate entries cannot diverge from its strong ETag body`() { + val release = wireRelease(wireBody(), admissionToken = 1) + assertTrue(store().save(userA, RemoteConfigSnapshotState(candidate = release))) + val storageKey = remoteConfigSnapshotStorageKey(userA) + cache.strings[storageKey] = requireNotNull(cache.strings[storageKey]).replaceFirst( + "\"rawBase64\":\"dHJ1ZQ==\"", + "\"rawBase64\":\"ZmFsc2U=\"", + ) + + assertNull(store().loadState(userA)?.candidate) + } + + @Test + fun `store isolates canonical users and never migrates or mutates legacy LKG`() { + cache.putString(LEGACY_LKG_KEY, "legacy-must-survive") + val store = store() + + assertTrue(store.save(userA, RemoteConfigSnapshotState(candidate = release("a", 1, "private-a")))) + + assertNull(store.loadState(userB)) + assertEquals("a", store.loadState(userA)?.candidate?.releaseUid) + assertEquals("legacy-must-survive", cache.getString(LEGACY_LKG_KEY, null)) + } + + @Test + fun `unknown or corrupt archive fails closed and clears only v2 storage`() { + cache.putString(LEGACY_LKG_KEY, "legacy-must-survive") + for (invalid in listOf( + "{not-json", + "{\"version\":999,\"records\":[]}", + "{\"version\":1,\"records\":[{\"unexpected\":true}]}", + )) { + val storageKey = remoteConfigSnapshotStorageKey(userA) + cache.putString(storageKey, invalid) + cache.putString( + REMOTE_CONFIG_SNAPSHOT_INDEX_KEY, + "{\"version\":1,\"storageKeys\":[\"$storageKey\"]}", + ) + + val result = store().load(userA) + assertEquals(RemoteConfigSnapshotLoadStatus.Corrupt, result.status) + assertNull(result.state) + + assertNull(cache.getString(storageKey, null)) + assertEquals("legacy-must-survive", cache.getString(LEGACY_LKG_KEY, null)) + } + } + + @Test + fun `failed durable save preserves prior whole state across restart`() { + val store = store() + val prior = RemoteConfigSnapshotState( + candidate = release("two", 2, "candidate"), + active = release("one", 1, "active"), + didActivate = true, + ) + assertTrue(store.save(userA, prior)) + cache.nextDurableUpdateResult = false + + val replacement = RemoteConfigSnapshotState( + candidate = release("three", 3, "replacement"), + active = release("three", 3, "replacement"), + previous = release("one", 1, "active"), + didActivate = true, + ) + assertFalse(store.save(userA, replacement)) + + val restarted = store().loadState(userA) + assertEquals("two", restarted?.candidate?.releaseUid) + assertEquals("one", restarted?.active?.releaseUid) + assertNull(restarted?.previous) + } + + @Test + fun `oversized replacement is rejected before durable storage changes`() { + val store = PersistentRemoteConfigSnapshotStore(cache, moshi, maxStateBytes = 900) + val prior = RemoteConfigSnapshotState(candidate = release("one", 1, "small")) + assertTrue(store.save(userA, prior)) + val writesBefore = cache.durableUpdates.size + val oversized = RemoteConfigSnapshotState( + candidate = release("two", 2, "x".repeat(2_000)), + ) + + assertFalse(store.save(userA, oversized)) + + assertEquals(writesBefore, cache.durableUpdates.size) + assertEquals("one", store().loadState(userA)?.candidate?.releaseUid) + } + + @Test + fun `scope archive is bounded and evicts least recently used scope atomically`() { + val store = PersistentRemoteConfigSnapshotStore(cache, moshi, maxScopes = 2) + val userC = RemoteConfigSnapshotScope("project", "production", "canonical-user-c") + assertTrue(store.save(userA, RemoteConfigSnapshotState(candidate = release("a", 1, "a")))) + assertTrue(store.save(userB, RemoteConfigSnapshotState(candidate = release("b", 1, "b")))) + assertEquals("a", store.loadState(userA)?.candidate?.releaseUid) + assertTrue(store.save(userC, RemoteConfigSnapshotState(candidate = release("c", 1, "c")))) + + assertNull(store.loadState(userB)) + assertEquals("a", store.loadState(userA)?.candidate?.releaseUid) + assertEquals("c", store.loadState(userC)?.candidate?.releaseUid) + } + + @Test + fun `default scope archive retains exactly sixteen most recently used identities`() { + val store = store() + val users = List(17) { index -> + RemoteConfigSnapshotScope("project", "production", "canonical-user-$index") + } + users.take(16).forEachIndexed { index, scope -> + assertTrue(store.save(scope, RemoteConfigSnapshotState(candidate = release("r$index", 1, "$index")))) + } + assertEquals("r0", store.loadState(users.first())?.candidate?.releaseUid) + + assertTrue(store.save(users.last(), RemoteConfigSnapshotState(candidate = release("r16", 1, "16")))) + + assertNull(store.loadState(users[1])) + assertEquals("r0", store.loadState(users.first())?.candidate?.releaseUid) + assertEquals("r16", store.loadState(users.last())?.candidate?.releaseUid) + } + + @Test + fun `saving another identity never rewrites the first identity payload`() { + val store = store() + assertTrue(store.save(userA, RemoteConfigSnapshotState(candidate = release("a", 1, "private-a")))) + cache.durableUpdates.clear() + + assertTrue(store.save(userB, RemoteConfigSnapshotState(candidate = release("b", 1, "private-b")))) + + val update = cache.durableUpdates.single() + assertFalse(update.values.containsKey(remoteConfigSnapshotStorageKey(userA))) + assertTrue(update.values.containsKey(remoteConfigSnapshotStorageKey(userB))) + assertTrue(update.values.containsKey(REMOTE_CONFIG_SNAPSHOT_INDEX_KEY)) + } + + @Test + fun `untrusted index can never remove an unrelated preference`() { + val unrelatedKey = "customer_auth_token" + cache.putString(unrelatedKey, "must-survive") + cache.putString( + REMOTE_CONFIG_SNAPSHOT_INDEX_KEY, + "{\"version\":1,\"storageKeys\":[\"$unrelatedKey\"]}", + ) + + assertTrue(store().save(userA, RemoteConfigSnapshotState(candidate = release("a", 1, "a")))) + + assertEquals("must-survive", cache.getString(unrelatedKey, null)) + assertFalse(cache.durableUpdates.last().removedKeys.contains(unrelatedKey)) + } + + @Test + fun `envelope self declared scope cannot cross the requested privacy scope`() { + val store = store() + assertTrue(store.save(userA, RemoteConfigSnapshotState(candidate = release("a", 1, "private-a")))) + val userAKey = remoteConfigSnapshotStorageKey(userA) + cache.strings[userAKey] = requireNotNull(cache.strings[userAKey]) + .replace("canonical-user-a", "canonical-user-b") + + assertNull(store.loadState(userA)) + assertNull(store.loadState(userB)) + } + + @Test + fun `oversized corrupt envelope cannot evict an admitted identity`() { + val userBKey = remoteConfigSnapshotStorageKey(userB) + val store = PersistentRemoteConfigSnapshotStore(cache, moshi, maxScopes = 2) + assertTrue(store.save(userA, RemoteConfigSnapshotState(candidate = release("a", 1, "private-a")))) + assertTrue(store.save(userB, RemoteConfigSnapshotState(candidate = release("b", 1, "private-b")))) + cache.strings[userBKey] = requireNotNull(cache.strings[userBKey]) + .replace("canonical-user-b", "x".repeat(257)) + + assertNull(store.loadState(userB)) + + assertEquals("a", store.loadState(userA)?.candidate?.releaseUid) + assertNull(cache.getString(userBKey, null)) + } + + @Test + fun `saving a new identity evicts corrupt envelope before admitted identity`() { + val userC = RemoteConfigSnapshotScope("project", "production", "canonical-user-c") + val userBKey = remoteConfigSnapshotStorageKey(userB) + val store = PersistentRemoteConfigSnapshotStore(cache, moshi, maxScopes = 2) + assertTrue(store.save(userA, RemoteConfigSnapshotState(candidate = release("a", 1, "private-a")))) + assertTrue(store.save(userB, RemoteConfigSnapshotState(candidate = release("b", 1, "private-b")))) + cache.strings[userBKey] = requireNotNull(cache.strings[userBKey]) + .replace("canonical-user-b", "x".repeat(257)) + + assertTrue(store.save(userC, RemoteConfigSnapshotState(candidate = release("c", 1, "private-c")))) + + assertEquals("a", store.loadState(userA)?.candidate?.releaseUid) + assertNull(store.loadState(userB)) + assertEquals("c", store.loadState(userC)?.candidate?.releaseUid) + } + + @Test + fun `failed durable read promotion never blocks a valid snapshot`() { + val store = PersistentRemoteConfigSnapshotStore(cache, moshi, maxScopes = 2) + assertTrue(store.save(userA, RemoteConfigSnapshotState(candidate = release("a", 1, "private-a")))) + assertTrue(store.save(userB, RemoteConfigSnapshotState(candidate = release("b", 1, "private-b")))) + cache.nextDurableUpdateResult = false + + assertEquals("a", store.loadState(userA)?.candidate?.releaseUid) + assertTrue(cache.nextDurableUpdateResult) + } + + @Test + fun `transient index read failure never deletes admitted state`() { + val store = store() + assertTrue(store.save(userA, RemoteConfigSnapshotState(candidate = release("a", 1, "private-a")))) + cache.throwOnNextGet += REMOTE_CONFIG_SNAPSHOT_INDEX_KEY + + val failedLoad = store.load(userA) + assertEquals(RemoteConfigSnapshotLoadStatus.Failed, failedLoad.status) + assertNull(failedLoad.state) + + assertEquals("a", store.loadState(userA)?.candidate?.releaseUid) + } + + @Test + fun `transient envelope read failure never deletes admitted state`() { + val store = store() + assertTrue(store.save(userA, RemoteConfigSnapshotState(candidate = release("a", 1, "private-a")))) + cache.throwOnNextGet += remoteConfigSnapshotStorageKey(userA) + + val failedLoad = store.load(userA) + assertEquals(RemoteConfigSnapshotLoadStatus.Failed, failedLoad.status) + assertNull(failedLoad.state) + + assertEquals("a", store.loadState(userA)?.candidate?.releaseUid) + } + + @Test + fun `transient index read failure rejects save without orphaning admitted state`() { + val store = store() + assertTrue(store.save(userA, RemoteConfigSnapshotState(candidate = release("a", 1, "private-a")))) + cache.throwOnNextGet += REMOTE_CONFIG_SNAPSHOT_INDEX_KEY + + assertFalse(store.save(userB, RemoteConfigSnapshotState(candidate = release("b", 1, "private-b")))) + + assertEquals("a", store.loadState(userA)?.candidate?.releaseUid) + assertNull(store.loadState(userB)) + } + + @Test + fun `corrupt index is rebuilt from an exact valid envelope without losing state`() { + val store = store() + val storageKey = remoteConfigSnapshotStorageKey(userA) + assertTrue(store.save(userA, RemoteConfigSnapshotState(candidate = release("a", 1, "private-a")))) + cache.putString(REMOTE_CONFIG_SNAPSHOT_INDEX_KEY, "{not-json") + + assertEquals("a", store.loadState(userA)?.candidate?.releaseUid) + + assertTrue(cache.getString(storageKey, null)?.isNotEmpty() == true) + assertEquals("a", store().loadState(userA)?.candidate?.releaseUid) + } + + @Test + fun `missing index is rebuilt from an exact valid envelope without losing state`() { + val store = store() + assertTrue(store.save(userA, RemoteConfigSnapshotState(candidate = release("a", 1, "private-a")))) + cache.remove(REMOTE_CONFIG_SNAPSHOT_INDEX_KEY) + + assertEquals("a", store.loadState(userA)?.candidate?.releaseUid) + + assertEquals("a", store().loadState(userA)?.candidate?.releaseUid) + } + + @Test + fun `corrupt candidate is discarded without erasing valid active and previous`() { + val store = store() + val storageKey = remoteConfigSnapshotStorageKey(userA) + val state = RemoteConfigSnapshotState( + candidate = release("three", 3, "candidate"), + active = release("two", 2, "active"), + previous = release("one", 1, "previous"), + didActivate = true, + ) + assertTrue(store.save(userA, state)) + cache.strings[storageKey] = requireNotNull(cache.strings[storageKey]).replace( + "\"releaseUid\":\"three\"", + "\"releaseUid\":\"${"x".repeat(37)}\"", + ) + + val salvaged = store.loadState(userA) + + assertNull(salvaged?.candidate) + assertEquals("two", salvaged?.active?.releaseUid) + assertEquals("one", salvaged?.previous?.releaseUid) + val restarted = store().loadState(userA) + assertNull(restarted?.candidate) + assertEquals("two", restarted?.active?.releaseUid) + assertEquals("one", restarted?.previous?.releaseUid) + } + + @Test + fun `verified candidate repairs a mutated active from the same local generation`() { + val store = store() + val storageKey = remoteConfigSnapshotStorageKey(userA) + val active = wireRelease(wireBody(), admissionToken = 7) + val mutatedActive = RemoteConfigSnapshotRelease( + releaseUid = active.releaseUid, + releaseNumber = active.releaseNumber, + manifestContentHash = active.manifestContentHash, + entries = listOf( + RemoteConfigSnapshotEntry.value( + key = "key", + rawValue = "false".encodeToByteArray(), + variationUid = "variation", + applyPolicy = RemoteConfigSnapshotApplyPolicy.Immediate, + metadata = "null".encodeToByteArray(), + ), + ), + contextFingerprint = active.contextFingerprint, + admissionToken = active.admissionToken, + ) + assertTrue( + store.save( + userA, + RemoteConfigSnapshotState( + candidate = active, + active = active, + didActivate = true, + ), + ), + ) + cache.strings[storageKey] = requireNotNull(cache.strings[storageKey]).replaceLastOccurrence( + "\"rawBase64\":\"dHJ1ZQ==\"", + "\"rawBase64\":\"ZmFsc2U=\"", + ).replaceLastOccurrence( + "\"contentDigest\":\"${active.contentDigest}\"", + "\"contentDigest\":\"${mutatedActive.contentDigest}\"", + ) + + val salvaged = store.loadState(userA) + + assertEquals("wire", salvaged?.candidate?.releaseUid) + assertEquals("wire", salvaged?.active?.releaseUid) + assertArrayEquals("true".encodeToByteArray(), salvaged?.active?.entry("key")?.rawValueBytes) + assertArrayEquals(wireBody(), salvaged?.active?.canonicalBodyBytes) + assertEquals(7L, salvaged?.active?.admissionToken) + assertEquals("wire", store().loadState(userA)?.candidate?.releaseUid) + } + + @Test + fun `verified candidate is retained when unverified active claims a newer local generation`() { + val store = store() + val storageKey = remoteConfigSnapshotStorageKey(userA) + val candidate = wireRelease(wireBody(), admissionToken = 7) + val forgedActive = RemoteConfigSnapshotRelease( + releaseUid = candidate.releaseUid, + releaseNumber = candidate.releaseNumber, + manifestContentHash = candidate.manifestContentHash, + entries = candidate.entries.values, + contextFingerprint = candidate.contextFingerprint, + admissionToken = 8, + ) + assertTrue( + store.save( + userA, + RemoteConfigSnapshotState(candidate = candidate, active = candidate, didActivate = true), + ), + ) + cache.strings[storageKey] = requireNotNull(cache.strings[storageKey]).replaceLastOccurrence( + "\"admissionToken\":7", + "\"admissionToken\":8", + ).replaceLastOccurrence( + "\"contentDigest\":\"${candidate.contentDigest}\"", + "\"contentDigest\":\"${forgedActive.contentDigest}\"", + ) + + val salvaged = store.loadState(userA) + + assertEquals("wire", salvaged?.candidate?.releaseUid) + assertNull(salvaged?.active) + assertEquals(7L, salvaged?.candidate?.admissionToken) + assertEquals(7L, store().loadState(userA)?.candidate?.admissionToken) + } + + @Test + fun `different local generations with the same server release survive restart`() { + val active = release("same-release-old-context", 7, "old", admissionToken = 11) + val candidate = release("same-release-new-context", 7, "new", admissionToken = 12) + val state = RemoteConfigSnapshotState( + candidate = candidate, + active = active, + didActivate = true, + ) + + assertTrue(store().save(userA, state)) + + val restarted = store().loadState(userA) + assertEquals("same-release-new-context", restarted?.candidate?.releaseUid) + assertEquals("same-release-old-context", restarted?.active?.releaseUid) + assertEquals(7L, restarted?.candidate?.releaseNumber) + assertEquals(7L, restarted?.active?.releaseNumber) + assertEquals(12L, restarted?.latestAdmissionToken) + } + + @Test + fun `persisted tombstones are integrity bound`() { + val value = release("new", 7, "new", admissionToken = 2) + val candidate = RemoteConfigSnapshotRelease( + releaseUid = value.releaseUid, + releaseNumber = value.releaseNumber, + manifestContentHash = value.manifestContentHash, + entries = value.entries.values + RemoteConfigSnapshotEntry.tombstone("removed"), + admissionToken = value.admissionToken, + contextFingerprint = value.contextFingerprint, + ) + assertTrue(store().save(userA, RemoteConfigSnapshotState(candidate = candidate))) + val storageKey = remoteConfigSnapshotStorageKey(userA) + cache.strings[storageKey] = requireNotNull(cache.strings[storageKey]).replace( + "\"key\":\"removed\"", + "\"key\":\"other-removed\"", + ) + + assertNull(store().loadState(userA)?.candidate) + } + + @Test + fun `corrupt latest admission MAX recovers validated slot high water without bricking fetches`() { + val persistentStore = store() + val state = RemoteConfigSnapshotState(candidate = release("seven", 7, "value", admissionToken = 7)) + assertTrue(persistentStore.save(userA, state)) + val storageKey = remoteConfigSnapshotStorageKey(userA) + cache.strings[storageKey] = requireNotNull(cache.strings[storageKey]).replace( + "\"latestAdmissionToken\":7", + "\"latestAdmissionToken\":${Long.MAX_VALUE}", + ) + + val recovered = persistentStore.loadState(userA) + + assertEquals("seven", recovered?.candidate?.releaseUid) + assertEquals(7L, recovered?.latestAdmissionToken) + val restartedCore = RemoteConfigSnapshotCore(store(), bundledRelease = null) + restartedCore.setScope(userA) + assertNotNull(restartedCore.beginAdmission(userA)) + } + + @Test + fun `state digest binds didActivate and ordered slots while recovering validated releases`() { + val active = release("active", 7, "active", admissionToken = 7) + val candidate = release("candidate", 7, "candidate", admissionToken = 8) + assertTrue( + store().save( + userA, + RemoteConfigSnapshotState( + candidate = candidate, + active = active, + didActivate = true, + ), + ), + ) + val storageKey = remoteConfigSnapshotStorageKey(userA) + val persisted = requireNotNull(cache.strings[storageKey]) + assertTrue(persisted.contains("\"stateDigest\":\"")) + cache.strings[storageKey] = persisted.replace("\"didActivate\":true", "\"didActivate\":false") + + val recovered = store().loadState(userA) + + assertTrue(recovered?.didActivate == true) + assertEquals("active", recovered?.active?.releaseUid) + assertEquals("candidate", recovered?.candidate?.releaseUid) + assertEquals(8L, recovered?.latestAdmissionToken) + } + + @Test + fun `state digest detects removed candidate slot and derives high water from remaining validated active`() { + val active = release("active", 7, "active", admissionToken = 7) + val candidate = release("candidate", 7, "candidate", admissionToken = 8) + assertTrue( + store().save( + userA, + RemoteConfigSnapshotState(candidate = candidate, active = active, didActivate = true), + ), + ) + val storageKey = remoteConfigSnapshotStorageKey(userA) + val adapter = moshi.adapter(PersistedRemoteConfigSnapshotEnvelope::class.java) + val envelope = requireNotNull(adapter.fromJson(requireNotNull(cache.strings[storageKey]))) + cache.strings[storageKey] = adapter.toJson( + envelope.copy(state = envelope.state.copy(candidate = null)), + ) + + val recovered = store().loadState(userA) + + assertNull(recovered?.candidate) + assertEquals("active", recovered?.active?.releaseUid) + assertEquals(7L, recovered?.latestAdmissionToken) + } + + @Test + fun `persisted snapshot envelope v1 is cold discarded without touching legacy LKG keys`() { + val storageKey = remoteConfigSnapshotStorageKey(userA) + val legacyPayloadKey = "qonversion_remote_config_lkg_${"f".repeat(64)}" + val legacyIndex = "{\"version\":1,\"scopes\":[]}" + cache.putString(legacyPayloadKey, "legacy-payload") + cache.putString(LEGACY_LKG_KEY, legacyIndex) + cache.putString(storageKey, persistedSnapshotEnvelopeV1()) + cache.putString( + REMOTE_CONFIG_SNAPSHOT_INDEX_KEY, + "{\"version\":1,\"storageKeys\":[\"$storageKey\"]}", + ) + + val result = store().load(userA) + + assertEquals(RemoteConfigSnapshotLoadStatus.Missing, result.status) + assertNull(cache.getString(storageKey, null)) + assertNull(cache.getString(REMOTE_CONFIG_SNAPSHOT_INDEX_KEY, null)) + assertEquals(legacyIndex, cache.getString(LEGACY_LKG_KEY, null)) + assertEquals("legacy-payload", cache.getString(legacyPayloadKey, null)) + } + + @Test + fun `malformed and oversized v1 lookalikes are corrupt rather than legacy missing`() { + val storageKey = remoteConfigSnapshotStorageKey(userA) + val malformedState = persistedSnapshotEnvelopeV1().replace( + "\"didActivate\":false", + "\"didActivate\":\"false\"", + ) + cache.putString(storageKey, malformedState) + assertEquals(RemoteConfigSnapshotLoadStatus.Corrupt, store().load(userA).status) + + cache.putString(storageKey, persistedSnapshotEnvelopeV1()) + val bounded = PersistentRemoteConfigSnapshotStore( + cache = cache, + moshi = moshi, + maxStateBytes = 128, + ) + assertEquals(RemoteConfigSnapshotLoadStatus.Corrupt, bounded.load(userA).status) + } + + @Test + fun `global persisted byte budget evicts least recently used envelopes`() { + val probe = store() + assertTrue(probe.save(userA, RemoteConfigSnapshotState(candidate = release("a", 1, "a")))) + val oneEnvelopeBytes = requireNotNull(cache.strings[remoteConfigSnapshotStorageKey(userA)]) + .toByteArray(Charsets.UTF_8).size + cache.strings.clear() + cache.durableUpdates.clear() + val userC = RemoteConfigSnapshotScope("project", "production", "canonical-user-c") + val boundedStore = PersistentRemoteConfigSnapshotStore( + cache = cache, + moshi = moshi, + maxTotalBytes = oneEnvelopeBytes * 2, + ) + assertTrue(boundedStore.save(userA, RemoteConfigSnapshotState(candidate = release("a", 1, "a")))) + assertTrue(boundedStore.save(userB, RemoteConfigSnapshotState(candidate = release("b", 1, "b")))) + + assertTrue(boundedStore.save(userC, RemoteConfigSnapshotState(candidate = release("c", 1, "c")))) + + assertNull(boundedStore.loadState(userA)) + assertEquals("b", boundedStore.loadState(userB)?.candidate?.releaseUid) + assertEquals("c", boundedStore.loadState(userC)?.candidate?.releaseUid) + val persistedBytes = listOf(userA, userB, userC).sumOf { scope -> + cache.strings[remoteConfigSnapshotStorageKey(scope)]?.toByteArray(Charsets.UTF_8)?.size ?: 0 + } + assertTrue(persistedBytes <= oneEnvelopeBytes * 2) + } + + private fun store() = PersistentRemoteConfigSnapshotStore(cache, moshi) + + private fun PersistentRemoteConfigSnapshotStore.loadState( + scope: RemoteConfigSnapshotScope, + ): RemoteConfigSnapshotState? = load(scope).state + + private fun release( + uid: String, + number: Long, + value: String, + admissionToken: Long = number, + ) = RemoteConfigSnapshotRelease( + releaseUid = uid, + releaseNumber = number, + manifestContentHash = "a".repeat(64), + admissionToken = admissionToken, + contextFingerprint = "c".repeat(64), + entries = listOf( + RemoteConfigSnapshotEntry.value( + key = "key", + rawValue = "\"$value\"".encodeToByteArray(), + variationUid = "variation-$uid", + applyPolicy = RemoteConfigSnapshotApplyPolicy.OnNextActivate, + metadata = null, + ), + ), + ) + + private fun strongETag(body: ByteArray): String = java.security.MessageDigest.getInstance("SHA-256") + .digest(body) + .joinToString(prefix = "\"", postfix = "\"", separator = "") { byte -> "%02x".format(byte) } + + private fun wireBody() = ( + "{\"schema_version\":1,\"project_id\":42,\"environment_uid\":\"production\"," + + "\"release_uid\":\"wire\",\"release_number\":1,\"manifest_content_hash\":\"${"a".repeat(64)}\"," + + "\"complete_key_set\":true,\"context_fingerprint\":\"${"b".repeat(64)}\"," + + "\"values\":{\"key\":{\"raw\":true,\"variation_uid\":\"variation\"," + + "\"apply_policy\":\"immediate\",\"metadata\":null}}}" + ).encodeToByteArray() + + private fun persistedSnapshotEnvelopeV1() = + "{\"version\":1,\"projectKey\":\"project\",\"environment\":\"production\"," + + "\"canonicalUserId\":\"canonical-user-a\",\"state\":{" + + "\"candidate\":{\"releaseUid\":\"legacy\",\"releaseNumber\":1," + + "\"manifestContentHash\":\"${"a".repeat(64)}\",\"entries\":[]," + + "\"canonicalBodyBase64\":null,\"strongETag\":null}," + + "\"active\":null,\"previous\":null,\"didActivate\":false}}" + + private fun wireRelease(body: ByteArray, admissionToken: Long): RemoteConfigSnapshotRelease { + val parsed = requireNotNull( + RemoteConfigSnapshotEnvelopeParser().parse( + body = body, + etag = strongETag(body), + expectation = RemoteConfigSnapshotEnvelopeExpectation( + projectId = 42, + environmentUid = "production", + ), + ), + ).release + return RemoteConfigSnapshotRelease( + releaseUid = parsed.releaseUid, + releaseNumber = parsed.releaseNumber, + manifestContentHash = parsed.manifestContentHash, + entries = parsed.entries.values, + canonicalBody = parsed.canonicalBodyBytes, + strongETag = parsed.strongETag, + admissionToken = admissionToken, + contextFingerprint = parsed.contextFingerprint, + ) + } + + private fun String.replaceLastOccurrence(oldValue: String, newValue: String): String { + val offset = lastIndexOf(oldValue) + require(offset >= 0) + return replaceRange(offset, offset + oldValue.length, newValue) + } + + private class SnapshotInMemoryCache : Cache { + data class DurableUpdate(val values: Map, val removedKeys: Set) + + val strings = mutableMapOf() + val durableUpdates = mutableListOf() + var nextDurableUpdateResult = true + val throwOnNextGet = mutableSetOf() + private val values = mutableMapOf() + + override fun putInt(key: String, value: Int) { values[key] = value } + override fun getInt(key: String, defValue: Int) = values[key] as? Int ?: defValue + override fun getBool(key: String, defValue: Boolean) = values[key] as? Boolean ?: defValue + override fun putBool(key: String, value: Boolean) { values[key] = value } + override fun putFloat(key: String, value: Float) { values[key] = value } + override fun getFloat(key: String, defValue: Float) = values[key] as? Float ?: defValue + override fun putLong(key: String, value: Long) { values[key] = value } + override fun getLong(key: String, defValue: Long) = values[key] as? Long ?: defValue + override fun putString(key: String, value: String?) { strings[key] = value } + override fun getString(key: String, defValue: String?): String? { + if (throwOnNextGet.remove(key)) error("transient read failure") + return strings[key] ?: defValue + } + override fun putObject(key: String, value: T, adapter: JsonAdapter) { + putString(key, adapter.toJson(value)) + } + override fun getObject(key: String, adapter: JsonAdapter): T? = + getString(key, null)?.let(adapter::fromJson) + override fun remove(key: String) { strings.remove(key); values.remove(key) } + override fun updateStringsDurably( + values: Map, + removedKeys: Set, + ): Boolean { + val result = nextDurableUpdateResult + nextDurableUpdateResult = true + if (!result) return false + durableUpdates += DurableUpdate(values.toMap(), removedKeys.toSet()) + removedKeys.forEach(strings::remove) + strings.putAll(values) + return true + } + } + + private companion object { + const val LEGACY_LKG_KEY = "qonversion_remote_config_lkg_index" + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCacheDurabilityTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCacheDurabilityTest.kt new file mode 100644 index 000000000..65ef2e8c1 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCacheDurabilityTest.kt @@ -0,0 +1,89 @@ +package com.qonversion.android.sdk.internal.storage + +import android.content.SharedPreferences +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class SharedPreferencesCacheDurabilityTest { + private val preferences = mockk() + private val editor = mockk() + private val cache = SharedPreferencesCache(preferences) + + @Test + fun `batch string update is durably committed as one transaction`() { + every { preferences.contains(any()) } returns false + every { preferences.edit() } returns editor + every { editor.remove("stale") } returns editor + every { editor.putString("current", "payload") } returns editor + every { editor.commit() } returns true + + val committed = cache.updateStringsDurably( + values = mapOf("current" to "payload"), + removedKeys = setOf("stale"), + ) + + assertTrue(committed) + verifyOrder { + editor.remove("stale") + editor.putString("current", "payload") + editor.commit() + } + verify(exactly = 0) { editor.apply() } + } + + @Test + fun `batch string update exposes a failed disk commit`() { + val rollbackEditor = mockk() + every { preferences.contains("current") } returns true + every { preferences.getString("current", null) } returns "previous" + every { preferences.edit() } returnsMany listOf(editor, rollbackEditor) + every { editor.putString("current", "payload") } returns editor + every { editor.commit() } returns false + every { rollbackEditor.putString("current", "previous") } returns rollbackEditor + every { rollbackEditor.apply() } returns Unit + + val committed = cache.updateStringsDurably( + values = mapOf("current" to "payload"), + removedKeys = emptySet(), + ) + + assertFalse(committed) + verifyOrder { + editor.putString("current", "payload") + editor.commit() + rollbackEditor.putString("current", "previous") + rollbackEditor.apply() + } + } + + @Test + fun `batch string update restores the prior in-process view when commit throws`() { + val rollbackEditor = mockk() + every { preferences.contains("current") } returns false + every { preferences.edit() } returnsMany listOf(editor, rollbackEditor) + every { editor.putString("current", "payload") } returns editor + every { editor.commit() } throws IllegalStateException("disk unavailable") + every { rollbackEditor.remove("current") } returns rollbackEditor + every { rollbackEditor.apply() } returns Unit + + assertThrows(IllegalStateException::class.java) { + cache.updateStringsDurably( + values = mapOf("current" to "payload"), + removedKeys = emptySet(), + ) + } + + verifyOrder { + editor.putString("current", "payload") + editor.commit() + rollbackEditor.remove("current") + rollbackEditor.apply() + } + } +} diff --git a/sdk/src/test/resources/remoteconfigv2/resolved-snapshot-v1.json b/sdk/src/test/resources/remoteconfigv2/resolved-snapshot-v1.json new file mode 100644 index 000000000..9ff6b335c --- /dev/null +++ b/sdk/src/test/resources/remoteconfigv2/resolved-snapshot-v1.json @@ -0,0 +1 @@ +{"schema_version":1,"project_id":42,"environment_uid":"env-production","release_uid":"release-uid","release_number":7,"manifest_content_hash":"05b3abf2579a5eb66403cd78be557fd860633a1fe2103c7642030defe32c657f","complete_key_set":true,"context_fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","values":{"alpha":{"raw":"value","variation_uid":"variation-a","apply_policy":"on_next_activate","metadata":null},"zeta":{"raw":{"nested":true},"variation_uid":"variation-z","apply_policy":"immediate","metadata":{"resetNavigation":true}}}}