From 2cb72d8033cc96d3872bb03bda408d85749829b3 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Tue, 4 Aug 2026 16:51:23 +0300 Subject: [PATCH 01/30] fix: restore API TLS verification --- .../sdk/internal/di/module/NetworkModule.kt | 15 ----------- .../internal/di/module/NetworkModuleTest.kt | 25 +++++++++++++++++++ 2 files changed, 25 insertions(+), 15 deletions(-) create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/di/module/NetworkModuleTest.kt 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/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..81674a730 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/di/module/NetworkModuleTest.kt @@ -0,0 +1,25 @@ +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 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 = mockk(relaxed = true) + + val client = NetworkModule().provideOkHttpClient(application, interceptor) + + assertFalse(client.hostnameVerifier.verify("attacker.invalid", sslSession)) + } +} From 5b9ffff954982da66f72a7796937d7dd7d1141a2 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Tue, 4 Aug 2026 16:59:39 +0300 Subject: [PATCH 02/30] test: use OkHttp hostname verifier accessor --- .../android/sdk/internal/di/module/NetworkModuleTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 81674a730..51b540a86 100644 --- 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 @@ -20,6 +20,6 @@ internal class NetworkModuleTest { val client = NetworkModule().provideOkHttpClient(application, interceptor) - assertFalse(client.hostnameVerifier.verify("attacker.invalid", sslSession)) + assertFalse(client.hostnameVerifier().verify("attacker.invalid", sslSession)) } } From fa673c0fc372a0779b62715032e057f676c315ae Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Tue, 4 Aug 2026 17:07:46 +0300 Subject: [PATCH 03/30] test: avoid mocking JDK TLS session --- .../sdk/internal/di/module/NetworkModuleTest.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 index 51b540a86..1331ac0ff 100644 --- 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 @@ -8,6 +8,8 @@ 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) @@ -16,7 +18,15 @@ internal class NetworkModuleTest { fun `api client rejects a hostname that does not match the certificate`() { val application = ApplicationProvider.getApplicationContext() val interceptor = mockk(relaxed = true) - val sslSession = 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) From 907c3dc7fecde3672d67bde372b7777c34b7987b Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Wed, 5 Aug 2026 12:56:06 +0300 Subject: [PATCH 04/30] fix: enforce TLS verification in NoCodes --- nocodes/build.gradle | 4 +- .../networkClient/NetworkClientImpl.kt | 19 -------- .../networkClient/NetworkClientImplTest.kt | 47 +++++++++++++++++++ 3 files changed, 50 insertions(+), 20 deletions(-) create mode 100644 nocodes/src/test/java/io/qonversion/nocodes/internal/networkLayer/networkClient/NetworkClientImplTest.kt 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 + } +} From 26261587a971b0dab3e614c7d4f6add141b3f6dd Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Tue, 4 Aug 2026 20:04:57 +0300 Subject: [PATCH 05/30] feat: persist Remote Config last-known-good --- .../android/sdk/QonversionConfig.kt | 6 +- .../dto/QRemoteConfigurationAssignmentType.kt | 4 +- .../sdk/internal/QProductCenterManager.kt | 18 +- .../sdk/internal/QRemoteConfigManager.kt | 686 +++++++-- .../sdk/internal/di/module/AppModule.kt | 11 + .../qonversion/android/sdk/internal/errors.kt | 3 +- .../internal/repository/DefaultRepository.kt | 15 +- .../android/sdk/internal/storage/Cache.kt | 6 + .../storage/PersistentRemoteConfigCache.kt | 477 +++++++ .../storage/SharedPreferencesCache.kt | 7 + ...roductCenterManagerIdentifyContractTest.kt | 9 +- .../sdk/internal/QProductCenterManagerTest.kt | 32 +- .../sdk/internal/QRemoteConfigManagerTest.kt | 1241 ++++++++++++++++- ...gurationSourceAssignmentTypeAdapterTest.kt | 39 + ...efaultRepositoryRemoteConfigParsingTest.kt | 163 +++ .../PersistentRemoteConfigCacheTest.kt | 761 ++++++++++ 16 files changed, 3280 insertions(+), 198 deletions(-) create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigCache.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/dto/QRemoteConfigurationSourceAssignmentTypeAdapterTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/repository/DefaultRepositoryRemoteConfigParsingTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigCacheTest.kt 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..aa727bf4e 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/QonversionConfig.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/QonversionConfig.kt @@ -83,9 +83,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. * 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/internal/QProductCenterManager.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/QProductCenterManager.kt index db3187160..33cca053a 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 @@ -241,8 +241,9 @@ internal class QProductCenterManager internal constructor( handlePendingRequests() fireIdentitySuccess(identityId) } else { - internalConfig.uid = qonversionUid - remoteConfigManager.onUserUpdate() + remoteConfigManager.onUserUpdate { + 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 { + 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 { + 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..525b45e97 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 @@ -10,6 +10,8 @@ import com.qonversion.android.sdk.dto.QonversionErrorCode 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.QonversionEmptyCallback import com.qonversion.android.sdk.listeners.QonversionExperimentAttachCallback import com.qonversion.android.sdk.listeners.QonversionRemoteConfigCallback @@ -20,18 +22,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 +104,11 @@ 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 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 +118,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 +139,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 @@ -118,23 +160,72 @@ internal class QRemoteConfigManager @Inject constructor( // stops in-flight loads from re-caching a superseded response. fun invalidateRemoteConfigsCache() = invalidateOnAnyThread {} - fun onUserUpdate() { - // Bump synchronously (see invalidateOnAnyThread) — the destructive - // map replacement still happens on main. - invalidationGeneration.incrementAndGet() - postToMainThread { - loadingStates = mutableMapOf() + fun onUserUpdate(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() + 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 +254,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 +265,219 @@ 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 - } - - // 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 - } - fireToCallbacks(contextKey) { onSuccess(remoteConfig) } + postIdentityAction { + if (requestIdentity.isCurrentAndStable()) { + loadRemoteConfigFromService( + contextKey, + loadingState, + generationAtStart, + requestIdentity, + ) + } 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) - } - } + 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.cacheScope, + remoteConfig, + ) } else { - null + handleRemoteConfigError( + contextKey, + loadingState, + requestIdentity.cacheScope, + malformedRemoteConfigResponseError(), + ) } + } 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) } + override fun onError(error: QonversionError) { + postIdentityAction { + if (requestIdentity.isCurrentAndStable()) { + handleRemoteConfigError(contextKey, loadingState, requestIdentity.cacheScope, error) + } else { + reissueSingleAfterUserChange(contextKey, loadingState) } - }) + } } }) } + 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, + cacheScope: RemoteConfigCacheScope?, + remoteConfig: QRemoteConfig, + ) { + loadingState.retryBaseline = null + val currentGeneration = invalidationGeneration.get() + if (currentGeneration == generationAtStart) { + cacheScope?.let { persistentCache.save(it, remoteConfig) } + 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 + if (error.code == QonversionErrorCode.RemoteConfigurationNotAvailable) { + // The server authoritatively evaluated this context and found no + // config. Keeping the old disk value would resurrect a removed + // assignment on the next transient outage. + cacheScope?.let { persistentCache.remove(it, contextKey) } + } + 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 +485,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 +582,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,56 +592,202 @@ 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.cacheScope, + 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) + } } + } + } + } - val baseRemoteConfigList = fallbackData?.remoteConfigList ?: run { - callback.onError(error) - return@onError - } + 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 remoteConfigList = if (contextKeys == null) { - baseRemoteConfigList.copy() - } 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()) - } + private fun malformedRemoteConfigResponseError() = QonversionError( + QonversionErrorCode.ResponseParsingFailed, + "Remote Config response does not match the request", + ) - // 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 handleRemoteConfigListSuccess( + contextKeys: List?, + includeEmptyContextKey: Boolean, + callback: QonversionRemoteConfigListCallback, + cacheScope: RemoteConfigCacheScope?, + generationAtStart: Int, + localLoadingStates: MutableMap, + remoteConfigList: QRemoteConfigList, + ) { + remoteConfigList.remoteConfigs.forEach { remoteConfig -> + deliveryOrigins[remoteConfig.source.contextKey] = QRemoteConfigDeliveryOrigin.Network + } + if (invalidationGeneration.get() == generationAtStart) { + cacheScope?.let { + reconcilePersistentCache( + contextKeys, + includeEmptyContextKey, + it, + remoteConfigList.remoteConfigs, + ) + } + 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, + ) { + if (contextKeys == null) { + persistentCache.replaceAll(cacheScope, remoteConfigs) + return + } + + val requestedContextKeys = buildList { + addAll(contextKeys) + if (includeEmptyContextKey) add(null) + }.toSet() + persistentCache.replaceRequested(cacheScope, requestedContextKeys, remoteConfigs) + } + + 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 { + QRemoteConfigDeliveryOrigin.BundledFallback + } + } + } + + 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) { @@ -442,4 +810,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/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/errors.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/errors.kt index 99a392b1e..b438cf4d0 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,7 @@ 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 org.json.JSONException import java.io.IOException @@ -39,7 +40,7 @@ internal fun BillingError.toQonversionError(): QonversionError { internal fun Throwable.toQonversionError(): QonversionError { return when (this) { - is JSONException -> { + is JSONException, is JsonDataException -> { QonversionError(QonversionErrorCode.ResponseParsingFailed, localizedMessage ?: "") } 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/storage/Cache.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/Cache.kt index 91b59fba9..ab46b470d 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,12 @@ 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) + } + /** * @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..fd5d0469f --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigCache.kt @@ -0,0 +1,477 @@ +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 + +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 remove(contextKey: String?) + fun remove(scope: RemoteConfigCacheScope, contextKey: String?) = remove(contextKey) + fun replaceAll(remoteConfigs: List) + fun replaceAll(scope: RemoteConfigCacheScope, remoteConfigs: List) = replaceAll(remoteConfigs) + fun replaceRequested(requestedContextKeys: Set, remoteConfigs: List) + fun replaceRequested( + scope: RemoteConfigCacheScope, + requestedContextKeys: Set, + remoteConfigs: List, + ) = replaceRequested(requestedContextKeys, remoteConfigs) + 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 var nextRevision = 0L + + @Synchronized + override fun save(remoteConfig: QRemoteConfig) { + val scope = currentScope() ?: return + save(scope, remoteConfig) + } + + @Synchronized + override fun save(scope: RemoteConfigCacheScope, remoteConfig: QRemoteConfig) { + if (!remoteConfig.isCorrect) return + + val currentConfigs = loadEnvelope(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) + } + + @Synchronized + override fun remove(contextKey: String?) { + val scope = currentScope() ?: return + remove(scope, contextKey) + } + + @Synchronized + override fun remove(scope: RemoteConfigCacheScope, contextKey: String?) { + val normalizedContextKey = contextKey.normalizedRemoteConfigContextKey() + val updatedConfigs = loadEnvelope(scope)?.remoteConfigs.orEmpty() + .filterNot { it.source.contextKey.normalizedRemoteConfigContextKey() == normalizedContextKey } + scheduleWrite(scope, updatedConfigs) + } + + @Synchronized + override fun replaceAll(remoteConfigs: List) { + val scope = currentScope() ?: return + replaceAll(scope, remoteConfigs) + } + + @Synchronized + override fun replaceAll(scope: RemoteConfigCacheScope, remoteConfigs: List) { + if (!remoteConfigs.areValidForPersistence()) return + + val previousEnvelope = loadEnvelope(scope) + scheduleWrite( + scope, + remoteConfigs.takeLast(limits.maxEntriesPerScope), + previousEnvelope, + ) + } + + @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, + ) { + if (remoteConfigs.any { !it.isCorrect }) return + + val normalizedRequestedKeys = requestedContextKeys + .mapTo(mutableSetOf()) { it.normalizedRemoteConfigContextKey() } + val returnedKeys = remoteConfigs.map { config -> + config.source.contextKey.normalizedRemoteConfigContextKey() + } + if (returnedKeys.size != returnedKeys.distinct().size || + returnedKeys.any { it !in normalizedRequestedKeys } + ) { + return + } + + val previousEnvelope = loadEnvelope(scope) + val updatedConfigs = previousEnvelope?.remoteConfigs.orEmpty() + .filterNot { config -> + config.source.contextKey.normalizedRemoteConfigContextKey() in normalizedRequestedKeys + } + .plus(remoteConfigs) + .takeLast(limits.maxEntriesPerScope) + scheduleWrite(scope, updatedConfigs, previousEnvelope) + } + + @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 -> + 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()) { + scheduleWrite(scope, remoteConfigs) + } + return QRemoteConfigList(remoteConfigs) + } + + private fun scheduleWrite( + scope: RemoteConfigCacheScope, + remoteConfigs: List, + previousEnvelope: PersistentRemoteConfigEnvelope? = memoryEnvelopes[scope.storageKey], + ) { + val storageKey = scope.storageKey + val revision = ++nextRevision + pendingRevisions[storageKey] = revision + val envelope = remoteConfigs.takeIf { it.isNotEmpty() }?.let { + PersistentRemoteConfigEnvelope( + version = CACHE_VERSION, + projectKey = scope.projectKey, + environment = scope.environment, + userId = scope.userId, + remoteConfigs = it, + ) + } + if (envelope == null) { + memoryEnvelopes.remove(storageKey) + } else { + memoryEnvelopes[storageKey] = envelope + } + persistenceExecutor.execute { + persistLatest(storageKey, revision, envelope, previousEnvelope) + } + } + + private fun persistLatest( + storageKey: String, + revision: Long, + envelope: PersistentRemoteConfigEnvelope?, + previousEnvelope: PersistentRemoteConfigEnvelope?, + ) { + val boundedEnvelopeAndJson = envelope?.let(::fitWithinByteLimit) + ?: envelope?.let { previousEnvelope?.let(::fitWithinByteLimit) } + synchronized(this) { + if (pendingRevisions[storageKey] != revision) return + + val boundedEnvelope = boundedEnvelopeAndJson?.first + val json = boundedEnvelopeAndJson?.second + val indexUpdate = createIndexUpdate( + storageKey, + json?.toByteArray(Charsets.UTF_8)?.size, + ) + if (boundedEnvelope == null || json == null) { + memoryEnvelopes.remove(storageKey) + } else { + memoryEnvelopes[storageKey] = boundedEnvelope + } + indexUpdate.evictedStorageKeys.forEach { evictedStorageKey -> + if (!pendingRevisions.containsKey(evictedStorageKey)) { + memoryEnvelopes.remove(evictedStorageKey) + } + } + 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 + cache.updateStrings(values, removedKeys) + if (pendingRevisions[storageKey] == revision) { + pendingRevisions.remove(storageKey) + } + } + } + + 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() } ?: run { + cache.remove(CACHE_INDEX_KEY) + 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 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, +) 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..4319d6dc4 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,13 @@ 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() + } + override fun getString(key: String, defValue: String?): String? = preferences.getString(key, defValue) 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..f204d6412 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()) } answers { + firstArg<() -> 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(any()) mockConfig.uid = mergedUid - mockRemoteConfigManager.onUserUpdate() mockLaunchResultCacheWrapper.clearPermissionsCache() mockRepository.init(match { it.requestTrigger == RequestTrigger.Identify }) } @@ -181,7 +184,7 @@ internal class QProductCenterManagerIdentifyContractTest { } verify(exactly = 1) { mockRemoteConfigManager.invalidateRemoteConfigsCache() } // ...and the destructive user-switch path must NOT fire on same-uid - verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate() } + verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate(any()) } } /** @@ -201,7 +204,7 @@ internal class QProductCenterManagerIdentifyContractTest { verify(exactly = 0) { mockIdentityManager.identify(any(), any()) } verify(exactly = 0) { mockRemoteConfigManager.invalidateRemoteConfigsCache() } - verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate() } + verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate(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..59c9ba65f 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()) } answers { + firstArg<() -> 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()) } verify(exactly = 0) { mockLaunchResultCacheWrapper.clearPermissionsCache() } verify { callback.onSuccess(any()) } } @@ -190,14 +193,33 @@ internal class QProductCenterManagerTest { verifyOrder { mockUserInfoService.storeQonversionUserId(originalOwnerUid) + mockRemoteConfigManager.onUserUpdate(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(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()) } 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()) } 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..7ccdaea28 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,531 @@ 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 `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 `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 +812,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 @@ -300,12 +824,150 @@ internal class QRemoteConfigManagerTest { loadingStates()["ctx"] = QRemoteConfigManager.LoadingState(loadedConfig = cachedConfig) val callback = mockk(relaxed = true) - // when - manager.loadRemoteConfigList(listOf("ctx"), false, callback) + // when + manager.loadRemoteConfigList(listOf("ctx"), false, callback) + + // 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()) - // then - the cached list is still served, but nothing is flushed - verify(exactly = 1) { callback.onSuccess(any()) } - verify(exactly = 0) { mockUserPropertiesManager.forceSendProperties(any()) } + 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 +1031,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 +1040,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 +1067,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 +1102,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 @@ -465,12 +1127,12 @@ internal class QRemoteConfigManagerTest { shadowOf(Looper.getMainLooper()).idle() // when - invalidation mid-flight, the superseded (valid) response - // triggers a re-issue, and the retry fails without a fallback + // triggers a re-issue, and the retry fails transiently 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)) + 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 @@ -480,6 +1142,56 @@ internal class QRemoteConfigManagerTest { 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", callback) + shadowOf(Looper.getMainLooper()).idle() + + manager.invalidateRemoteConfigsCache() + shadowOf(Looper.getMainLooper()).idle() + val supersededConfig = remoteConfigFor("ctx") + serviceCallbacks.first().onSuccess(supersededConfig) + val noConfig = QonversionError(QonversionErrorCode.RemoteConfigurationNotAvailable) + serviceCallbacks.last().onError(noConfig) + + verify(exactly = 1) { callback.onError(noConfig) } + verify(exactly = 0) { callback.onSuccess(any()) } + assertEquals(null, persistentCache.get("ctx")) + } + @Test fun `a failed re-issue prefers the baseline over the bundled fallback`() { // given - a bundled fallback EXISTS for the key, and a load with a @@ -505,7 +1217,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 +1243,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 +1274,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 +1315,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 +1348,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 +1384,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 +1400,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 +1705,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 +1839,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 +2039,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 @@ -1001,6 +2101,93 @@ internal class QRemoteConfigManagerTest { 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 { + val savedConfigs = mutableListOf() + var scope = RemoteConfigCacheScope("project", "Production", "user-a") + val savedScopes = mutableListOf() + var mutationCount = 0 + 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 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 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 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 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/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/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/storage/PersistentRemoteConfigCacheTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigCacheTest.kt new file mode 100644 index 000000000..a0a475565 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigCacheTest.kt @@ -0,0 +1,761 @@ +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 + +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 `serialization is queued off caller thread while memory value is immediately available`() { + val queuedExecutor = ManualExecutor() + val asyncCache = cache(config, executor = queuedExecutor) + val expected = remoteConfig(contextKey = "paywall", payloadValue = "v1") + + asyncCache.save(expected) + + assertTrue(backingCache.strings.isEmpty()) + assertEquals(expected, asyncCache.get("paywall")) + queuedExecutor.runAll() + assertEquals(expected, cache(config).get("paywall")) + } + + 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() + 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 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() + } + } + } + + 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" + } +} From 2d45083280a9693be1c253598a207729e78e2bc0 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Thu, 6 Aug 2026 00:01:58 +0300 Subject: [PATCH 06/30] fix: make Remote Config LKG writes durable --- .../sdk/internal/QRemoteConfigManager.kt | 206 +++++- .../android/sdk/internal/storage/Cache.kt | 4 + .../storage/PersistentRemoteConfigCache.kt | 390 ++++++++-- .../storage/SharedPreferencesCache.kt | 32 + .../sdk/internal/QRemoteConfigManagerTest.kt | 267 +++++++ .../PersistentRemoteConfigCacheTest.kt | 677 +++++++++++++++++- .../SharedPreferencesCacheDurabilityTest.kt | 89 +++ 7 files changed, 1572 insertions(+), 93 deletions(-) create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCacheDurabilityTest.kt 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 525b45e97..75cc39122 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 @@ -306,7 +306,7 @@ internal class QRemoteConfigManager @Inject constructor( contextKey, loadingState, generationAtStart, - requestIdentity.cacheScope, + requestIdentity, remoteConfig, ) } else { @@ -326,7 +326,19 @@ internal class QRemoteConfigManager @Inject constructor( override fun onError(error: QonversionError) { postIdentityAction { if (requestIdentity.isCurrentAndStable()) { - handleRemoteConfigError(contextKey, loadingState, requestIdentity.cacheScope, error) + if (error.code == QonversionErrorCode.RemoteConfigurationNotAvailable && + requestIdentity.cacheScope != null + ) { + handleAuthoritativeRemoteConfigRemoval( + contextKey, + loadingState, + generationAtStart, + requestIdentity, + error, + ) + } else { + handleRemoteConfigError(contextKey, loadingState, requestIdentity.cacheScope, error) + } } else { reissueSingleAfterUserChange(contextKey, loadingState) } @@ -335,6 +347,38 @@ internal class QRemoteConfigManager @Inject constructor( }) } + 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, @@ -351,13 +395,69 @@ internal class QRemoteConfigManager @Inject constructor( contextKey: String?, loadingState: LoadingState, generationAtStart: Int, - cacheScope: RemoteConfigCacheScope?, + 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) { - cacheScope?.let { persistentCache.save(it, remoteConfig) } deliveryOrigins[contextKey] = QRemoteConfigDeliveryOrigin.Network loadingState.loadedConfig = remoteConfig loadingState.generation = generationAtStart @@ -418,12 +518,6 @@ internal class QRemoteConfigManager @Inject constructor( ) { val baseline = loadingState.retryBaseline loadingState.retryBaseline = null - if (error.code == QonversionErrorCode.RemoteConfigurationNotAvailable) { - // The server authoritatively evaluated this context and found no - // config. Keeping the old disk value would resurrect a removed - // assignment on the next transient outage. - cacheScope?.let { persistentCache.remove(it, contextKey) } - } val canRecover = error.shouldFireRemoteConfigFallback val lastKnownGood = if (canRecover && cacheScope != null) { persistentCache.get(cacheScope, contextKey) @@ -619,7 +713,7 @@ internal class QRemoteConfigManager @Inject constructor( contextKeys, includeEmptyContextKey, callback, - requestIdentity.cacheScope, + requestIdentity, generationAtStart, localLoadingStates, remoteConfigList, @@ -665,11 +759,72 @@ internal class QRemoteConfigManager @Inject constructor( "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, - cacheScope: RemoteConfigCacheScope?, + 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()) + } + } + } + } + + private fun completeRemoteConfigListSuccess( + callback: QonversionRemoteConfigListCallback, generationAtStart: Int, localLoadingStates: MutableMap, remoteConfigList: QRemoteConfigList, @@ -677,22 +832,12 @@ internal class QRemoteConfigManager @Inject constructor( remoteConfigList.remoteConfigs.forEach { remoteConfig -> deliveryOrigins[remoteConfig.source.contextKey] = QRemoteConfigDeliveryOrigin.Network } - if (invalidationGeneration.get() == generationAtStart) { - cacheScope?.let { - reconcilePersistentCache( - contextKeys, - includeEmptyContextKey, - it, - remoteConfigList.remoteConfigs, - ) - } - remoteConfigList.remoteConfigs.forEach { remoteConfig -> - val contextKey = remoteConfig.source.contextKey - val loadingState = localLoadingStates[contextKey] ?: LoadingState() - loadingState.loadedConfig = remoteConfig - loadingState.generation = generationAtStart - localLoadingStates[contextKey] = loadingState - } + 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) @@ -703,9 +848,10 @@ internal class QRemoteConfigManager @Inject constructor( includeEmptyContextKey: Boolean, cacheScope: RemoteConfigCacheScope, remoteConfigs: List, + completion: (Boolean) -> Unit, ) { if (contextKeys == null) { - persistentCache.replaceAll(cacheScope, remoteConfigs) + persistentCache.replaceAll(cacheScope, remoteConfigs, completion) return } @@ -713,7 +859,7 @@ internal class QRemoteConfigManager @Inject constructor( addAll(contextKeys) if (includeEmptyContextKey) add(null) }.toSet() - persistentCache.replaceRequested(cacheScope, requestedContextKeys, remoteConfigs) + persistentCache.replaceRequested(cacheScope, requestedContextKeys, remoteConfigs, completion) } private fun remoteConfigListFallback( 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 ab46b470d..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 @@ -33,6 +33,10 @@ internal interface Cache { 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 index fd5d0469f..05a77d391 100644 --- 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 @@ -8,6 +8,7 @@ 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 @@ -36,16 +37,37 @@ 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 @@ -64,6 +86,8 @@ internal class PersistentRemoteConfigCache( 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 @@ -74,15 +98,41 @@ internal class PersistentRemoteConfigCache( @Synchronized override fun save(scope: RemoteConfigCacheScope, remoteConfig: QRemoteConfig) { - if (!remoteConfig.isCorrect) return + save(scope, remoteConfig, requireExactPersistence = false) {} + } - val currentConfigs = loadEnvelope(scope)?.remoteConfigs.orEmpty() + @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) + scheduleWrite( + scope, + updatedConfigs, + completion, + requireExactPersistence, + ) { committedEnvelope -> + committedEnvelope?.remoteConfigs?.any { it == remoteConfig } == true + } } @Synchronized @@ -93,10 +143,35 @@ internal class PersistentRemoteConfigCache( @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 = loadEnvelope(scope)?.remoteConfigs.orEmpty() + val updatedConfigs = loadLatestEnvelope(scope)?.remoteConfigs.orEmpty() .filterNot { it.source.contextKey.normalizedRemoteConfigContextKey() == normalizedContextKey } - scheduleWrite(scope, updatedConfigs) + scheduleWrite( + scope, + updatedConfigs, + completion, + requireExactPersistence, + ) { committedEnvelope -> + committedEnvelope?.remoteConfigs.orEmpty().none { + it.source.contextKey.normalizedRemoteConfigContextKey() == normalizedContextKey + } + } } @Synchronized @@ -107,13 +182,36 @@ internal class PersistentRemoteConfigCache( @Synchronized override fun replaceAll(scope: RemoteConfigCacheScope, remoteConfigs: List) { - if (!remoteConfigs.areValidForPersistence()) return + 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 + } - val previousEnvelope = loadEnvelope(scope) scheduleWrite( scope, remoteConfigs.takeLast(limits.maxEntriesPerScope), - previousEnvelope, + completion, + requireExactPersistence, ) } @@ -132,27 +230,68 @@ internal class PersistentRemoteConfigCache( requestedContextKeys: Set, remoteConfigs: List, ) { - if (remoteConfigs.any { !it.isCorrect }) return + 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() } - val returnedKeys = remoteConfigs.map { config -> - config.source.contextKey.normalizedRemoteConfigContextKey() - } - if (returnedKeys.size != returnedKeys.distinct().size || - returnedKeys.any { it !in normalizedRequestedKeys } - ) { + if (!remoteConfigs.areValidForRequestedPersistence(normalizedRequestedKeys)) { + completion(false) return } - val previousEnvelope = loadEnvelope(scope) - val updatedConfigs = previousEnvelope?.remoteConfigs.orEmpty() + val requestedUpdate = loadLatestEnvelope(scope)?.remoteConfigs.orEmpty() .filterNot { config -> config.source.contextKey.normalizedRemoteConfigContextKey() in normalizedRequestedKeys } .plus(remoteConfigs) - .takeLast(limits.maxEntriesPerScope) - scheduleWrite(scope, updatedConfigs, previousEnvelope) + 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 @@ -169,12 +308,14 @@ internal class PersistentRemoteConfigCache( it.source.contextKey.normalizedRemoteConfigContextKey() == normalizedContextKey } remoteConfig?.let { accessed -> - scheduleWrite( - scope, - envelope.remoteConfigs.filterNot { - it.source.contextKey.normalizedRemoteConfigContextKey() == normalizedContextKey - } + accessed, - ) + if (!pendingEnvelopes.containsKey(scope.storageKey)) { + scheduleWrite( + scope, + envelope.remoteConfigs.filterNot { + it.source.contextKey.normalizedRemoteConfigContextKey() == normalizedContextKey + } + accessed, + ) + } } return remoteConfig } @@ -188,7 +329,7 @@ internal class PersistentRemoteConfigCache( @Synchronized override fun getAll(scope: RemoteConfigCacheScope): QRemoteConfigList { val remoteConfigs = loadEnvelope(scope)?.remoteConfigs.orEmpty() - if (remoteConfigs.isNotEmpty()) { + if (remoteConfigs.isNotEmpty() && !pendingEnvelopes.containsKey(scope.storageKey)) { scheduleWrite(scope, remoteConfigs) } return QRemoteConfigList(remoteConfigs) @@ -197,11 +338,11 @@ internal class PersistentRemoteConfigCache( private fun scheduleWrite( scope: RemoteConfigCacheScope, remoteConfigs: List, - previousEnvelope: PersistentRemoteConfigEnvelope? = memoryEnvelopes[scope.storageKey], + completion: (Boolean) -> Unit = {}, + requireExactPersistence: Boolean = false, + isSuccessfulCommit: ((PersistentRemoteConfigEnvelope?) -> Boolean)? = null, ) { val storageKey = scope.storageKey - val revision = ++nextRevision - pendingRevisions[storageKey] = revision val envelope = remoteConfigs.takeIf { it.isNotEmpty() }?.let { PersistentRemoteConfigEnvelope( version = CACHE_VERSION, @@ -211,43 +352,114 @@ internal class PersistentRemoteConfigCache( remoteConfigs = it, ) } - if (envelope == null) { - memoryEnvelopes.remove(storageKey) - } else { - memoryEnvelopes[storageKey] = envelope + // 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 } - persistenceExecutor.execute { - persistLatest(storageKey, revision, envelope, previousEnvelope) + + 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, - envelope: PersistentRemoteConfigEnvelope?, - previousEnvelope: PersistentRemoteConfigEnvelope?, + persistencePayload: PersistencePayload, ) { - val boundedEnvelopeAndJson = envelope?.let(::fitWithinByteLimit) - ?: envelope?.let { previousEnvelope?.let(::fitWithinByteLimit) } - synchronized(this) { - if (pendingRevisions[storageKey] != revision) return + 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) + ) + } + } - val boundedEnvelope = boundedEnvelopeAndJson?.first - val json = boundedEnvelopeAndJson?.second + 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, ) - if (boundedEnvelope == null || json == null) { - memoryEnvelopes.remove(storageKey) - } else { - memoryEnvelopes[storageKey] = boundedEnvelope - } - indexUpdate.evictedStorageKeys.forEach { evictedStorageKey -> - if (!pendingRevisions.containsKey(evictedStorageKey)) { - memoryEnvelopes.remove(evictedStorageKey) - } - } val values = buildMap { json?.let { put(storageKey, it) } indexUpdate.index?.let { put(CACHE_INDEX_KEY, indexAdapter.toJson(it)) } @@ -257,13 +469,36 @@ internal class PersistentRemoteConfigCache( addAll(indexUpdate.evictedStorageKeys) if (indexUpdate.index == null) add(CACHE_INDEX_KEY) } - values.keys - cache.updateStrings(values, removedKeys) - if (pendingRevisions[storageKey] == revision) { - pendingRevisions.remove(storageKey) + 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? { @@ -326,10 +561,7 @@ internal class PersistentRemoteConfigCache( } else { null } - return index?.takeIf { it.isValid() } ?: run { - cache.remove(CACHE_INDEX_KEY) - emptyIndex() - } + return index?.takeIf { it.isValid() } ?: emptyIndex() } private fun PersistentRemoteConfigIndex.isValid(): Boolean { @@ -407,6 +639,18 @@ internal class PersistentRemoteConfigCache( 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? { @@ -475,3 +719,29 @@ 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/SharedPreferencesCache.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCache.kt index 4319d6dc4..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 @@ -38,6 +38,38 @@ internal class SharedPreferencesCache( }.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/test/java/com/qonversion/android/sdk/internal/QRemoteConfigManagerTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/QRemoteConfigManagerTest.kt index 7ccdaea28..653aa38d4 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 @@ -76,6 +76,166 @@ internal class QRemoteConfigManagerTest { 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 @@ -201,6 +361,63 @@ internal class QRemoteConfigManagerTest { 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 @@ -2108,10 +2325,17 @@ internal class QRemoteConfigManagerTest { } 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 @@ -2127,6 +2351,12 @@ internal class QRemoteConfigManagerTest { 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) } @@ -2137,6 +2367,12 @@ internal class QRemoteConfigManagerTest { 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) } @@ -2152,6 +2388,12 @@ internal class QRemoteConfigManagerTest { } } + override fun replaceAll( + scope: RemoteConfigCacheScope, + remoteConfigs: List, + completion: (Boolean) -> Unit, + ) = enqueueDurableMutation({ replaceAll(scope, remoteConfigs) }, completion) + override fun replaceRequested( requestedContextKeys: Set, remoteConfigs: List, @@ -2177,6 +2419,31 @@ internal class QRemoteConfigManagerTest { } } + 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? = 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 index a0a475565..baa35b0e2 100644 --- 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 @@ -24,6 +24,7 @@ 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() @@ -599,19 +600,628 @@ internal class PersistentRemoteConfigCacheTest { } @Test - fun `serialization is queued off caller thread while memory value is immediately available`() { + 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(expected) + asyncCache.save(requireNotNull(asyncCache.currentScope()), expected) { result -> + committed = result + } assertTrue(backingCache.strings.isEmpty()) - assertEquals(expected, asyncCache.get("paywall")) + 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(), @@ -691,6 +1301,8 @@ internal class PersistentRemoteConfigCacheTest { 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 } @@ -707,6 +1319,16 @@ internal class PersistentRemoteConfigCacheTest { 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)) @@ -731,6 +1353,55 @@ internal class PersistentRemoteConfigCacheTest { 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 { 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() + } + } +} From 97d6dd09bd5ffae63e5dfa3aba059306a89c0b1d Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Wed, 5 Aug 2026 23:19:31 +0300 Subject: [PATCH 07/30] feat: add bundled Remote Config defaults --- .../com/qonversion/android/sdk/Qonversion.kt | 21 + .../sdk/dto/QRemoteConfigFallbackValue.kt | 13 + .../services/BundledRemoteConfigDefaults.kt | 445 ++++++++++++++++++ ...nversionBundledRemoteConfigDefaultsTest.kt | 60 +++ .../BundledRemoteConfigDefaultsReaderTest.kt | 432 +++++++++++++++++ 5 files changed, 971 insertions(+) create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/QRemoteConfigFallbackValue.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaults.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/QonversionBundledRemoteConfigDefaultsTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaultsReaderTest.kt diff --git a/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt b/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt index 703566402..1f0787450 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt @@ -1,15 +1,18 @@ package com.qonversion.android.sdk import android.app.Activity +import android.content.Context import android.net.Uri import android.util.Log import com.qonversion.android.sdk.dto.QAttributionProvider import com.qonversion.android.sdk.dto.QPurchaseOptions import com.qonversion.android.sdk.dto.QPurchaseResult +import com.qonversion.android.sdk.dto.QRemoteConfigFallbackValue import com.qonversion.android.sdk.dto.products.QProduct import com.qonversion.android.sdk.dto.properties.QUserPropertyKey import com.qonversion.android.sdk.internal.InternalConfig import com.qonversion.android.sdk.internal.QonversionInternal +import com.qonversion.android.sdk.internal.services.BundledRemoteConfigDefaults import com.qonversion.android.sdk.listeners.QonversionEmptyCallback import com.qonversion.android.sdk.listeners.QonversionExperimentAttachCallback import com.qonversion.android.sdk.listeners.QDeferredPurchasesListener @@ -49,6 +52,24 @@ interface Qonversion { "the initialize method before accessing the shared instance of Qonversion." ) + /** + * Reads a Remote Config default directly from the generated asset bundled with the app. + * + * This synchronous API is independent of SDK initialization, networking, identity and + * caches. Put the generated `qonversion_remote_config_defaults.json` file in the app's + * `assets` directory and call this method with its logical [contextKey]. A non-null + * wrapper whose [QRemoteConfigFallbackValue.rawValue] is null represents a present JSON + * `null`; a null wrapper means the key is absent or the bundle failed strict validation. + * + * @param context any Android context used only to access the application asset. + * @param contextKey logical Remote Config key from the generated bundle. + */ + @JvmStatic + fun fallbackRemoteConfigValue( + context: Context, + contextKey: String, + ): QRemoteConfigFallbackValue? = BundledRemoteConfigDefaults.value(context, contextKey) + /** * An entry point to use Qonversion SDK. Call to initialize Qonversion SDK with required and extra configs. * The function is the best way to set additional configs you need to use Qonversion SDK. diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/QRemoteConfigFallbackValue.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/QRemoteConfigFallbackValue.kt new file mode 100644 index 000000000..80756ea99 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/QRemoteConfigFallbackValue.kt @@ -0,0 +1,13 @@ +package com.qonversion.android.sdk.dto + +/** + * A value read directly from the Remote Config defaults bundled with the app. + * + * [rawValue] is one of the JSON-compatible Kotlin values: [String], [Double], + * [Boolean], an immutable [List], an immutable [Map], or `null`. The wrapper + * itself remains non-null for a present JSON `null`, so callers can distinguish + * that value from a missing or invalid bundled key. + */ +class QRemoteConfigFallbackValue internal constructor( + val rawValue: Any?, +) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaults.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaults.kt new file mode 100644 index 000000000..af2b55696 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaults.kt @@ -0,0 +1,445 @@ +package com.qonversion.android.sdk.internal.services + +import android.content.Context +import com.qonversion.android.sdk.dto.QRemoteConfigFallbackValue +import com.squareup.moshi.JsonReader +import okio.Buffer +import okio.ByteString.Companion.decodeBase64 +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.math.BigInteger +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.Collections + +internal const val BUNDLED_REMOTE_CONFIG_DEFAULTS_FILE_NAME = "qonversion_remote_config_defaults.json" +internal const val BUNDLED_REMOTE_CONFIG_DEFAULTS_MAX_BYTES = 8 * 1024 * 1024 +internal const val BUNDLED_REMOTE_CONFIG_DEFAULT_VALUE_MAX_BYTES = 64 * 1024 +internal const val BUNDLED_REMOTE_CONFIG_DEFAULT_JSON_MAX_DEPTH = 64 + +private const val BUNDLED_REMOTE_CONFIG_DEFAULTS_SCHEMA_VERSION = 1 +private const val BUNDLED_REMOTE_CONFIG_READ_BUFFER_BYTES = 8 * 1024 +private const val BUNDLED_REMOTE_CONFIG_DEFAULTS_MAX_KEYS = 1_000 +private const val BUNDLED_REMOTE_CONFIG_LOGICAL_KEY_MAX_BYTES = 256 +private const val BUNDLED_REMOTE_CONFIG_UID_MAX_CODE_POINTS = 36 +private const val BUNDLED_REMOTE_CONFIG_DEFAULTS_DIGEST_DOMAIN = + "qonversion.remote-config-fallback-defaults.v1" +private const val PORTABLE_JSON_MAX_INTEGER = 9_007_199_254_740_991L +private val PORTABLE_JSON_MAX_INTEGER_BIG = BigInteger.valueOf(PORTABLE_JSON_MAX_INTEGER) +private val PORTABLE_JSON_MIN_INTEGER_BIG = PORTABLE_JSON_MAX_INTEGER_BIG.negate() +private val LOWERCASE_SHA256_PATTERN = Regex("^[0-9a-f]{64}$") + +internal fun interface BundledRemoteConfigDefaultsAssetSource { + fun open(context: Context): InputStream +} + +internal class BundledRemoteConfigDefaultsReader( + private val assetSource: BundledRemoteConfigDefaultsAssetSource = + BundledRemoteConfigDefaultsAssetSource { context -> + context.assets.open(BUNDLED_REMOTE_CONFIG_DEFAULTS_FILE_NAME) + }, + private val maxArtifactBytes: Int = BUNDLED_REMOTE_CONFIG_DEFAULTS_MAX_BYTES, +) { + fun read(context: Context): BundledRemoteConfigDefaultsDocument? { + return try { + val bytes = assetSource.open(context).use { it.readBounded(maxArtifactBytes) } ?: return null + parse(bytes) + } catch (_: Exception) { + null + } + } + + @Suppress("ComplexMethod", "ComplexCondition", "ReturnCount") + private fun parse(bytes: ByteArray): BundledRemoteConfigDefaultsDocument? { + val source = bytes.decodeStrictUtf8() ?: return null + val reader = JsonReader.of(Buffer().write(bytes)) + reader.isLenient = false + + return try { + reader.beginObject() + if (!reader.readExpectedName("schemaVersion")) return null + val schemaVersion = reader.nextInt() + if (!reader.readExpectedName("projectId")) return null + val projectId = reader.nextLong() + if (!reader.readExpectedName("environmentUid")) return null + val environmentUid = reader.nextString() + if (!reader.readExpectedName("releaseUid")) return null + val releaseUid = reader.nextString() + if (!reader.readExpectedName("releaseNumber")) return null + val releaseNumber = reader.nextLong() + if (!reader.readExpectedName("manifestContentHash")) return null + val manifestContentHash = reader.nextString() + if (!reader.readExpectedName("defaultsDigest")) return null + val defaultsDigest = reader.nextString() + if (!reader.readExpectedName("defaults")) return null + val defaults = readDefaults(reader) ?: return null + if (reader.hasNext()) return null + reader.endObject() + if (reader.peek() != JsonReader.Token.END_DOCUMENT) return null + + if (schemaVersion != BUNDLED_REMOTE_CONFIG_DEFAULTS_SCHEMA_VERSION || + projectId <= 0 || projectId > PORTABLE_JSON_MAX_INTEGER || + releaseNumber <= 0 || releaseNumber > PORTABLE_JSON_MAX_INTEGER || + !environmentUid.isValidRemoteConfigUid() || !releaseUid.isValidRemoteConfigUid() || + !LOWERCASE_SHA256_PATTERN.matches(manifestContentHash) || + !LOWERCASE_SHA256_PATTERN.matches(defaultsDigest) + ) { + return null + } + + val computedDigest = computeDefaultsDigest( + schemaVersion = schemaVersion, + projectId = projectId, + environmentUid = environmentUid, + releaseUid = releaseUid, + releaseNumber = releaseNumber, + manifestContentHash = manifestContentHash, + defaults = defaults, + ) + if (computedDigest != defaultsDigest) return null + + val document = BundledRemoteConfigDefaultsDocument( + projectId = projectId, + environmentUid = environmentUid, + releaseUid = releaseUid, + releaseNumber = releaseNumber, + manifestContentHash = manifestContentHash, + defaultsDigest = defaultsDigest, + defaults = defaults, + ) + if (document.canonicalJson() != source) return null + document + } catch (_: Exception) { + null + } + } + + @Suppress("ComplexMethod", "ComplexCondition", "ReturnCount") + private fun readDefaults(reader: JsonReader): List? { + val defaults = mutableListOf() + reader.beginArray() + var previousKeyBytes: ByteArray? = null + while (reader.hasNext()) { + if (defaults.size == BUNDLED_REMOTE_CONFIG_DEFAULTS_MAX_KEYS) return null + reader.beginObject() + if (!reader.readExpectedName("key")) return null + val key = reader.nextString() + if (!reader.readExpectedName("variationUid")) return null + val variationUid = reader.nextString() + if (!reader.readExpectedName("valueBase64")) return null + val valueBase64 = reader.nextString() + if (reader.hasNext()) return null + reader.endObject() + + val keyBytes = key.toByteArray(StandardCharsets.UTF_8) + if (keyBytes.isEmpty() || keyBytes.size > BUNDLED_REMOTE_CONFIG_LOGICAL_KEY_MAX_BYTES || + !variationUid.isValidRemoteConfigUid() || + previousKeyBytes?.let { compareUnsignedUtf8(it, keyBytes) >= 0 } == true + ) { + return null + } + previousKeyBytes = keyBytes + + val decoded = valueBase64.decodeBase64() ?: return null + if (decoded.base64() != valueBase64) return null + val rawJson = decoded.toByteArray() + if (rawJson.isEmpty() || rawJson.size > BUNDLED_REMOTE_CONFIG_DEFAULT_VALUE_MAX_BYTES || + rawJson.decodeStrictUtf8() == null + ) { + return null + } + val parsedValue = parsePortableJson(rawJson) ?: return null + defaults += BundledRemoteConfigDefault( + key = key, + variationUid = variationUid, + valueBase64 = valueBase64, + rawJson = rawJson, + parsedValue = parsedValue.value, + ) + } + reader.endArray() + return defaults + } +} + +internal class BundledRemoteConfigDefaultsCache( + private val reader: BundledRemoteConfigDefaultsReader, +) { + @Volatile + private var loaded: LoadedDocument? = null + + fun value(context: Context, contextKey: String): QRemoteConfigFallbackValue? { + val document = loadedDocument(context) ?: return null + return document.defaultFor(contextKey)?.toPublicValue() + } + + private fun loadedDocument(context: Context): BundledRemoteConfigDefaultsDocument? { + val existing = loaded + if (existing != null) return existing.document + return synchronized(this) { + val rechecked = loaded + if (rechecked != null) { + rechecked.document + } else { + reader.read(context).also { loaded = LoadedDocument(it) } + } + } + } + + private data class LoadedDocument(val document: BundledRemoteConfigDefaultsDocument?) +} + +internal object BundledRemoteConfigDefaults { + @Volatile + private var cache = defaultCache() + + fun value(context: Context, contextKey: String): QRemoteConfigFallbackValue? = + cache.value(context, contextKey) + + @Synchronized + internal fun installReaderForTests(reader: BundledRemoteConfigDefaultsReader) { + cache = BundledRemoteConfigDefaultsCache(reader) + } + + @Synchronized + internal fun resetForTests() { + cache = defaultCache() + } + + private fun defaultCache() = BundledRemoteConfigDefaultsCache(BundledRemoteConfigDefaultsReader()) +} + +internal class BundledRemoteConfigDefaultsDocument( + val projectId: Long, + val environmentUid: String, + val releaseUid: String, + val releaseNumber: Long, + val manifestContentHash: String, + val defaultsDigest: String, + defaults: List, +) { + private val orderedDefaults = Collections.unmodifiableList(defaults.toList()) + private val defaultsByKey = Collections.unmodifiableMap(orderedDefaults.associateBy { it.key }) + + fun defaultFor(key: String): BundledRemoteConfigDefault? = defaultsByKey[key] + + internal fun canonicalJson(): String = buildString { + append("{\"schemaVersion\":1,\"projectId\":").append(projectId) + append(",\"environmentUid\":").appendGoJsonString(environmentUid) + append(",\"releaseUid\":").appendGoJsonString(releaseUid) + append(",\"releaseNumber\":").append(releaseNumber) + append(",\"manifestContentHash\":\"").append(manifestContentHash).append('"') + append(",\"defaultsDigest\":\"").append(defaultsDigest).append('"') + append(",\"defaults\":[") + orderedDefaults.forEachIndexed { index, value -> + if (index > 0) append(',') + append("{\"key\":").appendGoJsonString(value.key) + append(",\"variationUid\":").appendGoJsonString(value.variationUid) + append(",\"valueBase64\":\"").append(value.valueBase64).append("\"}") + } + append("]}") + } +} + +internal class BundledRemoteConfigDefault( + val key: String, + val variationUid: String, + val valueBase64: String, + rawJson: ByteArray, + private val parsedValue: Any?, +) { + private val storedRawJson = rawJson.clone() + val rawJsonBytes: ByteArray get() = storedRawJson.clone() + + fun toPublicValue() = QRemoteConfigFallbackValue(parsedValue) + + internal fun digestBytes(): ByteArray = storedRawJson.clone() +} + +private data class ParsedJson(val value: Any?) + +private fun parsePortableJson(bytes: ByteArray): ParsedJson? = try { + val reader = JsonReader.of(Buffer().write(bytes)) + reader.isLenient = false + val value = reader.readPortableJsonValue(depth = 1) + if (reader.peek() != JsonReader.Token.END_DOCUMENT) null else ParsedJson(value) +} catch (_: Exception) { + null +} + +@Suppress("ComplexMethod") +private fun JsonReader.readPortableJsonValue(depth: Int): Any? = when (peek()) { + JsonReader.Token.BEGIN_ARRAY -> { + if (depth > BUNDLED_REMOTE_CONFIG_DEFAULT_JSON_MAX_DEPTH) error("JSON is too deep") + beginArray() + val result = mutableListOf() + while (hasNext()) result += readPortableJsonValue(depth + 1) + endArray() + Collections.unmodifiableList(result) + } + JsonReader.Token.BEGIN_OBJECT -> { + if (depth > BUNDLED_REMOTE_CONFIG_DEFAULT_JSON_MAX_DEPTH) error("JSON is too deep") + beginObject() + val result = linkedMapOf() + while (hasNext()) { + val name = nextName() + if (!name.hasValidSurrogatePairs()) error("unpaired surrogate in JSON object member") + if (result.containsKey(name)) error("duplicate JSON object member") + result[name] = readPortableJsonValue(depth + 1) + } + endObject() + Collections.unmodifiableMap(result) + } + JsonReader.Token.STRING -> nextString().also { + if (!it.hasValidSurrogatePairs()) error("unpaired surrogate in JSON string") + } + JsonReader.Token.NUMBER -> { + val token = nextString() + if (token.isPlainJsonInteger()) { + val integer = BigInteger(token) + if (integer < PORTABLE_JSON_MIN_INTEGER_BIG || integer > PORTABLE_JSON_MAX_INTEGER_BIG) { + error("JSON integer is outside the portable range") + } + } + token.toDouble().takeIf(Double::isFinite) ?: error("JSON number is not finite binary64") + } + JsonReader.Token.BOOLEAN -> nextBoolean() + JsonReader.Token.NULL -> nextNull() + else -> error("expected one JSON value") +} + +private fun String.isPlainJsonInteger(): Boolean = none { it == '.' || it == 'e' || it == 'E' } + +private fun String.hasValidSurrogatePairs(): Boolean { + var index = 0 + var valid = true + while (index < length && valid) { + val current = this[index] + when { + current.isHighSurrogate() -> { + valid = index + 1 < length && this[index + 1].isLowSurrogate() + if (valid) index += 2 + } + current.isLowSurrogate() -> valid = false + else -> index += 1 + } + } + return valid +} + +private fun computeDefaultsDigest( + schemaVersion: Int, + projectId: Long, + environmentUid: String, + releaseUid: String, + releaseNumber: Long, + manifestContentHash: String, + defaults: List, +): String { + val digest = MessageDigest.getInstance("SHA-256") + digest.writeLengthPrefixed(BUNDLED_REMOTE_CONFIG_DEFAULTS_DIGEST_DOMAIN.toByteArray()) + digest.writeLengthPrefixed(schemaVersion.toString().toByteArray()) + digest.writeLengthPrefixed(projectId.toString().toByteArray()) + digest.writeLengthPrefixed(environmentUid.toByteArray()) + digest.writeLengthPrefixed(releaseUid.toByteArray()) + digest.writeLengthPrefixed(releaseNumber.toString().toByteArray()) + digest.writeLengthPrefixed(manifestContentHash.toByteArray()) + digest.writeLengthPrefixed(defaults.size.toString().toByteArray()) + defaults.forEach { value -> + digest.writeLengthPrefixed(value.key.toByteArray()) + digest.writeLengthPrefixed(value.variationUid.toByteArray()) + digest.writeLengthPrefixed(value.digestBytes()) + } + return digest.digest().toLowercaseHex() +} + +private fun MessageDigest.writeLengthPrefixed(value: ByteArray) { + update(ByteBuffer.allocate(Long.SIZE_BYTES).putLong(value.size.toLong()).array()) + update(value) +} + +private fun ByteArray.toLowercaseHex(): String = buildString(size * 2) { + for (byte in this@toLowercaseHex) { + val value = byte.toInt() and BYTE_MASK + append(HEX[value ushr HEX_HIGH_NIBBLE_SHIFT]) + append(HEX[value and HEX_NIBBLE_MASK]) + } +} + +private fun JsonReader.readExpectedName(expected: String): Boolean = + hasNext() && nextName() == expected + +private fun String.isValidRemoteConfigUid(): Boolean = + isNotEmpty() && codePointCount(0, length) <= BUNDLED_REMOTE_CONFIG_UID_MAX_CODE_POINTS + +private fun compareUnsignedUtf8(left: ByteArray, right: ByteArray): Int { + val sharedLength = minOf(left.size, right.size) + for (index in 0 until sharedLength) { + val comparison = (left[index].toInt() and BYTE_MASK) + .compareTo(right[index].toInt() and BYTE_MASK) + if (comparison != 0) return comparison + } + return left.size.compareTo(right.size) +} + +private fun InputStream.readBounded(maxBytes: Int): ByteArray? { + val output = ByteArrayOutputStream(minOf(maxBytes, BUNDLED_REMOTE_CONFIG_READ_BUFFER_BYTES)) + val buffer = ByteArray(BUNDLED_REMOTE_CONFIG_READ_BUFFER_BYTES) + var total = 0 + var read = read(buffer) + while (read >= 0) { + if (read > 0) { + if (read > maxBytes - total) return null + output.write(buffer, 0, read) + total += read + } + read = read(buffer) + } + return output.toByteArray() +} + +private fun ByteArray.decodeStrictUtf8(): String? = try { + StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(this)) + .toString() +} catch (_: Exception) { + null +} + +@Suppress("ComplexMethod") +private fun StringBuilder.appendGoJsonString(value: String): StringBuilder { + append('"') + value.forEach { character -> + when (character) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + '\b' -> append("\\b") + '\u000c' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + '<' -> append("\\u003c") + '>' -> append("\\u003e") + '&' -> append("\\u0026") + '\u2028' -> append("\\u2028") + '\u2029' -> append("\\u2029") + else -> if (character < ' ') { + append("\\u00") + append(HEX[(character.code ushr HEX_HIGH_NIBBLE_SHIFT) and HEX_NIBBLE_MASK]) + append(HEX[character.code and HEX_NIBBLE_MASK]) + } else { + append(character) + } + } + } + return append('"') +} + +private const val BYTE_MASK = 0xff +private const val HEX_HIGH_NIBBLE_SHIFT = 4 +private const val HEX_NIBBLE_MASK = 0x0f +private const val HEX = "0123456789abcdef" diff --git a/sdk/src/test/java/com/qonversion/android/sdk/QonversionBundledRemoteConfigDefaultsTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/QonversionBundledRemoteConfigDefaultsTest.kt new file mode 100644 index 000000000..f0225afd0 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/QonversionBundledRemoteConfigDefaultsTest.kt @@ -0,0 +1,60 @@ +package com.qonversion.android.sdk + +import android.content.Context +import com.qonversion.android.sdk.internal.services.BundledRemoteConfigDefaults +import com.qonversion.android.sdk.internal.services.BundledRemoteConfigDefaultsAssetSource +import com.qonversion.android.sdk.internal.services.BundledRemoteConfigDefaultsReader +import io.mockk.mockk +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import java.io.ByteArrayInputStream + +internal class QonversionBundledRemoteConfigDefaultsTest { + @After + fun resetProcessCache() { + BundledRemoteConfigDefaults.resetForTests() + } + + @Test + fun `static fallback getter works without initializing Qonversion`() { + val context = mockk(relaxed = true) + BundledRemoteConfigDefaults.installReaderForTests( + BundledRemoteConfigDefaultsReader( + BundledRemoteConfigDefaultsAssetSource { + ByteArrayInputStream(SERVER_GOLDEN_ARTIFACT.toByteArray()) + }, + ), + ) + + val value = Qonversion.fallbackRemoteConfigValue(context, "alpha") + + assertNotNull(value) + assertEquals(mapOf("message" to "Привет 👋"), value?.rawValue) + assertNull(Qonversion.fallbackRemoteConfigValue(context, "missing")) + } + + @Test + fun `Java static API shape accepts only Context and context key`() { + val method = Qonversion::class.java.getMethod( + "fallbackRemoteConfigValue", + Context::class.java, + String::class.java, + ) + + assertEquals("com.qonversion.android.sdk.dto.QRemoteConfigFallbackValue", method.returnType.name) + } + + private companion object { + const val SERVER_GOLDEN_ARTIFACT = + "{\"schemaVersion\":1,\"projectId\":42,\"environmentUid\":\"env-production\"," + + "\"releaseUid\":\"release-portable\",\"releaseNumber\":7," + + "\"manifestContentHash\":\"0291766e896e3f36aca5385082d74e8bd70fabf12ee776b7dfa2cd4d961c9dea\"," + + "\"defaultsDigest\":\"9e4dfcb4069901c3af4341383c814b24cdc39b20491f7a4593e0036d01f39540\"," + + "\"defaults\":[{\"key\":\"alpha\",\"variationUid\":\"variation-alpha\"," + + "\"valueBase64\":\"IHsgIm1lc3NhZ2UiOiAi0J/RgNC40LLQtdGCIPCfkYsiIH0gCg==\"}," + + "{\"key\":\"beta\",\"variationUid\":\"variation-beta\",\"valueBase64\":\"bnVsbA==\"}]}" + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaultsReaderTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaultsReaderTest.kt new file mode 100644 index 000000000..84ab5b5bc --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaultsReaderTest.kt @@ -0,0 +1,432 @@ +package com.qonversion.android.sdk.internal.services + +import android.content.Context +import okio.Buffer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.Base64 +import java.util.concurrent.Callable +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +internal class BundledRemoteConfigDefaultsReaderTest { + private val context = io.mockk.mockk(relaxed = true) + + @After + fun resetProcessCache() { + BundledRemoteConfigDefaults.resetForTests() + } + + @Test + fun `server golden parses with exact raw bytes and header`() { + val goldenBytes = SERVER_GOLDEN_ARTIFACT.toByteArray() + assertEquals( + SERVER_GOLDEN_ARTIFACT_SHA256, + MessageDigest.getInstance("SHA-256").digest(goldenBytes).joinToString("") { "%02x".format(it) }, + ) + val document = reader(goldenBytes).read(context) + + assertNotNull(document) + requireNotNull(document) + assertEquals(42L, document.projectId) + assertEquals("env-production", document.environmentUid) + assertEquals("release-portable", document.releaseUid) + assertEquals(7L, document.releaseNumber) + assertEquals(SERVER_GOLDEN_MANIFEST_CONTENT_HASH, document.manifestContentHash) + assertEquals(SERVER_GOLDEN_DEFAULTS_DIGEST, document.defaultsDigest) + assertEquals( + " { \"message\": \"Привет 👋\" } \n".toByteArray().toList(), + requireNotNull(document.defaultFor("alpha")).rawJsonBytes.toList(), + ) + assertEquals("variation-alpha", document.defaultFor("alpha")?.variationUid) + assertEquals("null", String(requireNotNull(document.defaultFor("beta")).rawJsonBytes)) + } + + @Test + fun `all JSON value kinds and Unicode are exposed including wrapped null`() { + val cache = BundledRemoteConfigDefaultsCache(reader(SERVER_ALL_TYPES_ARTIFACT.toByteArray())) + + assertEquals(listOf(1.0, "two", false), cache.value(context, "array")?.rawValue) + assertEquals(true, cache.value(context, "bool")?.rawValue) + val nullValue = cache.value(context, "null") + assertNotNull("a present JSON null must be distinguishable from a missing key", nullValue) + assertNull(nullValue?.rawValue) + assertEquals(42.5, cache.value(context, "number")?.rawValue) + assertEquals( + mapOf("enabled" to true, "nested" to "value"), + cache.value(context, "object")?.rawValue, + ) + assertEquals("hello", cache.value(context, "string")?.rawValue) + assertEquals("Привет 👋", cache.value(context, "unicode")?.rawValue) + assertNull(cache.value(context, "missing")) + } + + @Test + fun `corrupt or noncanonical artifacts fail closed`() { + val valid = String(artifact(listOf(default("alpha", "true"), default("beta", "null")))) + val invalidCases = mapOf( + "digest" to valid.replace( + Regex("\\\"defaultsDigest\\\":\\\"[0-9a-f]{64}\\\""), + "\"defaultsDigest\":\"${"0".repeat(64)}\"", + ).toByteArray(), + "base64" to valid.replace( + Regex("\\\"valueBase64\\\":\\\"[^\\\"]+\\\""), + "\"valueBase64\":\"***\"", + ).toByteArray(), + "json" to artifact(listOf(default("alpha", "not-json"))), + "schema" to artifact(listOf(default("alpha", "true")), schemaVersion = 2), + "manifest hash uppercase" to valid.replace( + SERVER_GOLDEN_MANIFEST_CONTENT_HASH, + SERVER_GOLDEN_MANIFEST_CONTENT_HASH.uppercase(), + ).toByteArray(), + "unsorted" to artifact(listOf(default("beta", "null"), default("alpha", "true"))), + "duplicate" to artifact(listOf(default("alpha", "true"), default("alpha", "false"))), + "missing field" to valid.replace(Regex(",\\\"releaseUid\\\":\\\"[^\\\"]+\\\""), "").toByteArray(), + "unknown field" to valid.replaceFirst("{", "{\"unknown\":true,").toByteArray(), + "duplicate field" to valid.replace( + "{\"schemaVersion\":1,", + "{\"schemaVersion\":1,\"schemaVersion\":1,", + ).toByteArray(), + "noncanonical whitespace" to " $valid".toByteArray(), + "legacy fallback" to "{\"remote_config_list\":[]}".toByteArray(), + ) + + invalidCases.forEach { (name, bytes) -> + assertNull(name, reader(bytes).read(context)) + } + } + + @Test + fun `header identifiers and collection bounds are enforced`() { + val validDefault = default("alpha", "true") + val invalidCases = mapOf( + "zero project id" to artifact(listOf(validDefault), projectId = 0), + "negative project id" to artifact(listOf(validDefault), projectId = -1), + "zero release number" to artifact(listOf(validDefault), releaseNumber = 0), + "negative release number" to artifact(listOf(validDefault), releaseNumber = -1), + "empty environment uid" to artifact(listOf(validDefault), environmentUid = ""), + "long environment uid" to artifact(listOf(validDefault), environmentUid = "e".repeat(37)), + "empty release uid" to artifact(listOf(validDefault), releaseUid = ""), + "long release uid" to artifact(listOf(validDefault), releaseUid = "r".repeat(37)), + "empty key" to artifact(listOf(DefaultFixture("", "variation", "true".toByteArray()))), + "key over 256 UTF-8 bytes" to artifact( + listOf(DefaultFixture("é".repeat(129), "variation", "true".toByteArray())), + ), + "empty variation uid" to artifact( + listOf(DefaultFixture("alpha", "", "true".toByteArray())), + ), + "long variation uid" to artifact( + listOf(DefaultFixture("alpha", "v".repeat(37), "true".toByteArray())), + ), + "more than 1000 defaults" to artifact( + List(1_001) { index -> default("key-${index.toString().padStart(4, '0')}", "true") }, + ), + ) + + invalidCases.forEach { (name, bytes) -> + assertNull(name, reader(bytes).read(context)) + } + + assertNotNull( + "UID limits are measured in Unicode code points", + reader(artifact(listOf(validDefault), environmentUid = "😀".repeat(36))).read(context), + ) + assertNotNull( + "key limit is inclusive and measured in UTF-8 bytes", + reader( + artifact(listOf(DefaultFixture("é".repeat(128), "variation", "true".toByteArray()))), + ).read(context), + ) + } + + @Test + fun `default entries reject missing duplicate unknown and noncanonical fields`() { + val valid = String(artifact(listOf(default("alpha", "true")))) + val invalidCases = mapOf( + "missing variation uid" to valid.replace( + Regex(",\"variationUid\":\"[^\"]+\""), + "", + ), + "duplicate key field" to valid.replace( + "{\"key\":\"alpha\",", + "{\"key\":\"alpha\",\"key\":\"alpha\",", + ), + "unknown default field" to valid.replace( + "{\"key\":\"alpha\",", + "{\"unknown\":true,\"key\":\"alpha\",", + ), + "unpadded base64" to valid.replace("dHJ1ZQ==", "dHJ1ZQ"), + ) + + invalidCases.forEach { (name, artifact) -> + assertNull(name, reader(artifact.toByteArray()).read(context)) + } + } + + @Test + fun `invalid UTF-8 and oversized asset fail closed`() { + val invalidUtf8 = artifact(listOf(default("alpha", "true"))).also { + it[it.indexOf('a'.code.toByte())] = 0x80.toByte() + } + + assertNull(reader(invalidUtf8).read(context)) + assertNull(reader(ByteArray(BUNDLED_REMOTE_CONFIG_DEFAULTS_MAX_BYTES + 1) { ' '.code.toByte() }).read(context)) + } + + @Test + fun `value larger than serving limit and JSON deeper than serving limit fail closed`() { + val tooLarge = "\"${"x".repeat(BUNDLED_REMOTE_CONFIG_DEFAULT_VALUE_MAX_BYTES)}\"" + val tooDeep = "[".repeat(BUNDLED_REMOTE_CONFIG_DEFAULT_JSON_MAX_DEPTH + 1) + + "null" + "]".repeat(BUNDLED_REMOTE_CONFIG_DEFAULT_JSON_MAX_DEPTH + 1) + + assertNull(reader(artifact(listOf(default("alpha", tooLarge)))).read(context)) + assertNull(reader(artifact(listOf(default("alpha", tooDeep)))).read(context)) + } + + @Test + fun `portable number profile accepts shared boundaries and rejects divergence`() { + val finite = BundledRemoteConfigDefaultsCache( + reader(SERVER_NUMBER_BOUNDARIES_ARTIFACT.toByteArray()), + ) + + assertEquals(1e308, finite.value(context, "float_max")?.rawValue) + assertEquals(9_007_199_254_740_991.0, finite.value(context, "int_max")?.rawValue) + assertEquals(-9_007_199_254_740_991.0, finite.value(context, "int_min")?.rawValue) + listOf("1e309", "9007199254740992", "-9007199254740992").forEach { number -> + assertNull(number, reader(artifact(listOf(default("number", number)))).read(context)) + } + assertNull( + "project ID outside the portable exact integer range", + reader(artifact(listOf(default("number", "1")), projectId = 9_007_199_254_740_992L)).read(context), + ) + assertNull( + "release number outside the portable exact integer range", + reader(artifact(listOf(default("number", "1")), releaseNumber = 9_007_199_254_740_992L)).read(context), + ) + } + + @Test + fun `escaped surrogate pairs match the producer portable Unicode profile`() { + val valid = BundledRemoteConfigDefaultsCache( + reader(SERVER_ESCAPED_SURROGATE_ARTIFACT.toByteArray()), + ) + assertEquals("👋", valid.value(context, "escaped_pair")?.rawValue) + + listOf("\"\\ud83d\"", "\"\\ud83d\\u0041\"", "\"\\udc4b\"").forEach { raw -> + assertNull(raw, reader(artifact(listOf(default("unicode", raw)))).read(context)) + } + } + + @Test + fun `process cache loads asset once under concurrency and returns immutable values`() { + val reads = AtomicInteger() + val source = BundledRemoteConfigDefaultsAssetSource { + reads.incrementAndGet() + ByteArrayInputStream( + artifact( + listOf(default("object", "{\"nested\":[{\"value\":1}]}")), + ), + ) + } + val cache = BundledRemoteConfigDefaultsCache(BundledRemoteConfigDefaultsReader(source)) + val executor = Executors.newFixedThreadPool(8) + + val results = executor.invokeAll( + List(64) { Callable { cache.value(context, "object") } }, + ).map { it.get(5, TimeUnit.SECONDS) } + executor.shutdownNow() + + assertEquals(1, reads.get()) + assertTrue(results.all { it?.rawValue == mapOf("nested" to listOf(mapOf("value" to 1.0))) }) + val first = requireNotNull(results.first()).rawValue as Map<*, *> + @Suppress("UNCHECKED_CAST") + val mutable = first as MutableMap + assertThrows(UnsupportedOperationException::class.java) { mutable["mutated"] = true } + assertEquals( + mapOf("nested" to listOf(mapOf("value" to 1.0))), + cache.value(context, "object")?.rawValue, + ) + } + + @Test + fun `raw bytes and digest bytes are defensive copies`() { + val document = requireNotNull( + reader(artifact(listOf(default("alpha", "true")))).read(context), + ) + val entry = requireNotNull(document.defaultFor("alpha")) + + entry.rawJsonBytes[0] = 'f'.code.toByte() + entry.digestBytes()[0] = 'f'.code.toByte() + + assertEquals("true", String(entry.rawJsonBytes)) + assertEquals("true", String(entry.digestBytes())) + } + + @Test + fun `invalid artifact is negatively cached`() { + val reads = AtomicInteger() + val cache = BundledRemoteConfigDefaultsCache( + BundledRemoteConfigDefaultsReader( + BundledRemoteConfigDefaultsAssetSource { + reads.incrementAndGet() + ByteArrayInputStream("not-json".toByteArray()) + }, + ), + ) + + repeat(10) { assertNull(cache.value(context, "any")) } + + assertEquals(1, reads.get()) + } + + private fun reader(bytes: ByteArray) = BundledRemoteConfigDefaultsReader( + BundledRemoteConfigDefaultsAssetSource { ByteArrayInputStream(bytes) }, + ) + + private data class DefaultFixture( + val key: String, + val variationUid: String, + val value: ByteArray, + ) + + private fun default(key: String, rawJson: String) = DefaultFixture( + key = key, + variationUid = "variation-$key", + value = rawJson.toByteArray(), + ) + + private fun artifact( + defaults: List, + schemaVersion: Int = 1, + projectId: Long = 42, + environmentUid: String = "env-production", + releaseUid: String = "release-portable", + releaseNumber: Long = 7, + manifestContentHash: String = SERVER_GOLDEN_MANIFEST_CONTENT_HASH, + ): ByteArray { + val digest = defaultsDigest( + schemaVersion, + projectId, + environmentUid, + releaseUid, + releaseNumber, + manifestContentHash, + defaults, + ) + return buildString { + append("{\"schemaVersion\":").append(schemaVersion) + append(",\"projectId\":").append(projectId) + append(",\"environmentUid\":").append(jsonString(environmentUid)) + append(",\"releaseUid\":").append(jsonString(releaseUid)) + append(",\"releaseNumber\":").append(releaseNumber) + append(",\"manifestContentHash\":\"").append(manifestContentHash).append('"') + append(",\"defaultsDigest\":\"").append(digest).append('"') + append(",\"defaults\":[") + defaults.forEachIndexed { index, value -> + if (index > 0) append(',') + append("{\"key\":").append(jsonString(value.key)) + append(",\"variationUid\":").append(jsonString(value.variationUid)) + append(",\"valueBase64\":\"") + .append(Base64.getEncoder().encodeToString(value.value)) + .append("\"}") + } + append("]}") + }.toByteArray() + } + + private fun defaultsDigest( + schemaVersion: Int, + projectId: Long, + environmentUid: String, + releaseUid: String, + releaseNumber: Long, + manifestContentHash: String, + defaults: List, + ): String { + val digest = MessageDigest.getInstance("SHA-256") + fun part(bytes: ByteArray) { + digest.update(ByteBuffer.allocate(Long.SIZE_BYTES).putLong(bytes.size.toLong()).array()) + digest.update(bytes) + } + fun part(value: String) = part(value.toByteArray(StandardCharsets.UTF_8)) + + part("qonversion.remote-config-fallback-defaults.v1") + part(schemaVersion.toString()) + part(projectId.toString()) + part(environmentUid) + part(releaseUid) + part(releaseNumber.toString()) + part(manifestContentHash) + part(defaults.size.toString()) + defaults.forEach { value -> + part(value.key) + part(value.variationUid) + part(value.value) + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + private fun jsonString(value: String): String { + val buffer = Buffer() + val writer = com.squareup.moshi.JsonWriter.of(buffer) + writer.value(value) + writer.close() + return buffer.readUtf8() + } + + private companion object { + // Replaced only if the server contract golden changes. This is copied + // byte-for-byte from configurator's fallback_artifact_test.go. + const val SERVER_GOLDEN_ARTIFACT = + "{\"schemaVersion\":1,\"projectId\":42,\"environmentUid\":\"env-production\"," + + "\"releaseUid\":\"release-portable\",\"releaseNumber\":7," + + "\"manifestContentHash\":\"0291766e896e3f36aca5385082d74e8bd70fabf12ee776b7dfa2cd4d961c9dea\"," + + "\"defaultsDigest\":\"9e4dfcb4069901c3af4341383c814b24cdc39b20491f7a4593e0036d01f39540\"," + + "\"defaults\":[{\"key\":\"alpha\",\"variationUid\":\"variation-alpha\"," + + "\"valueBase64\":\"IHsgIm1lc3NhZ2UiOiAi0J/RgNC40LLQtdGCIPCfkYsiIH0gCg==\"}," + + "{\"key\":\"beta\",\"variationUid\":\"variation-beta\",\"valueBase64\":\"bnVsbA==\"}]}" + const val SERVER_GOLDEN_MANIFEST_CONTENT_HASH = + "0291766e896e3f36aca5385082d74e8bd70fabf12ee776b7dfa2cd4d961c9dea" + const val SERVER_GOLDEN_DEFAULTS_DIGEST = + "9e4dfcb4069901c3af4341383c814b24cdc39b20491f7a4593e0036d01f39540" + const val SERVER_GOLDEN_ARTIFACT_SHA256 = + "db09d5051c9702773d6dedb4023c8099b20f0cf49daf0471f553c44d4ceaafc5" + const val SERVER_ALL_TYPES_ARTIFACT = + "{\"schemaVersion\":1,\"projectId\":42,\"environmentUid\":\"env-production\"," + + "\"releaseUid\":\"release-all-json-types\",\"releaseNumber\":8," + + "\"manifestContentHash\":\"aa6a89035cd667e719c826029b46d063107991009382955b2332974fea7e417d\"," + + "\"defaultsDigest\":\"05237b0c07eac66a5ae1f7268c9afef3f0170b8649d22559388a4071ee81e95f\"," + + "\"defaults\":[{\"key\":\"array\",\"variationUid\":\"variation-array\",\"valueBase64\":\"WzEsInR3byIsZmFsc2Vd\"}," + + "{\"key\":\"bool\",\"variationUid\":\"variation-bool\",\"valueBase64\":\"dHJ1ZQ==\"}," + + "{\"key\":\"null\",\"variationUid\":\"variation-null\",\"valueBase64\":\"bnVsbA==\"}," + + "{\"key\":\"number\",\"variationUid\":\"variation-number\",\"valueBase64\":\"NDIuNQ==\"}," + + "{\"key\":\"object\",\"variationUid\":\"variation-object\",\"valueBase64\":\"eyJlbmFibGVkIjp0cnVlLCJuZXN0ZWQiOiJ2YWx1ZSJ9\"}," + + "{\"key\":\"string\",\"variationUid\":\"variation-string\",\"valueBase64\":\"ImhlbGxvIg==\"}," + + "{\"key\":\"unicode\",\"variationUid\":\"variation-unicode\",\"valueBase64\":\"ItCf0YDQuNCy0LXRgiDwn5GLIg==\"}]}" + const val SERVER_NUMBER_BOUNDARIES_ARTIFACT = + "{\"schemaVersion\":1,\"projectId\":42,\"environmentUid\":\"env-production\"," + + "\"releaseUid\":\"release-number-boundaries\",\"releaseNumber\":9," + + "\"manifestContentHash\":\"3896effad7acc9270542cb9c9ce76975c65888a19f6efc6eafba299e8e75be4b\"," + + "\"defaultsDigest\":\"0f8138237ed0fc124a161e6dcc2f5fe6e3fa78691b0dea2dbf7f42bc66736d8d\"," + + "\"defaults\":[{\"key\":\"float_max\",\"variationUid\":\"variation-float_max\",\"valueBase64\":\"MWUzMDg=\"}," + + "{\"key\":\"int_max\",\"variationUid\":\"variation-int_max\",\"valueBase64\":\"OTAwNzE5OTI1NDc0MDk5MQ==\"}," + + "{\"key\":\"int_min\",\"variationUid\":\"variation-int_min\",\"valueBase64\":\"LTkwMDcxOTkyNTQ3NDA5OTE=\"}]}" + const val SERVER_ESCAPED_SURROGATE_ARTIFACT = + "{\"schemaVersion\":1,\"projectId\":42,\"environmentUid\":\"env-production\"," + + "\"releaseUid\":\"release-escaped-surrogate\",\"releaseNumber\":10," + + "\"manifestContentHash\":\"e2d2e6cd92b6aca3bae40d4196bcedb6beade7b3dbf672bc13193b3dd9e7ad02\"," + + "\"defaultsDigest\":\"474f2f24b12594d138755a501c2868553a158201ddc955d1c9f5fc7900c8ce44\"," + + "\"defaults\":[{\"key\":\"escaped_pair\",\"variationUid\":\"variation-escaped-pair\"," + + "\"valueBase64\":\"Ilx1ZDgzZFx1ZGM0YiI=\"}]}" + } +} From 59c3808866b753b2b102184a63472947664306b6 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Thu, 6 Aug 2026 01:51:31 +0300 Subject: [PATCH 08/30] feat: add durable remote config snapshot core --- .../remoteconfig/RemoteConfigSnapshot.kt | 325 +++++++++++ .../remoteconfig/RemoteConfigSnapshotCore.kt | 290 ++++++++++ .../services/BundledRemoteConfigDefaults.kt | 3 + .../PersistentRemoteConfigSnapshotStore.kt | 515 ++++++++++++++++++ .../RemoteConfigSnapshotCoreTest.kt | 430 +++++++++++++++ .../remoteconfig/RemoteConfigSnapshotTest.kt | 271 +++++++++ ...PersistentRemoteConfigSnapshotStoreTest.kt | 447 +++++++++++++++ 7 files changed, 2281 insertions(+) create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshot.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCore.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStore.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCoreTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStoreTest.kt 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..0d73508f6 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshot.kt @@ -0,0 +1,325 @@ +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.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, + ) + } +} + +internal class RemoteConfigSnapshotRelease( + val releaseUid: String, + val releaseNumber: Long, + val manifestContentHash: String, + entries: Collection, +) { + private val entriesByKey: Map + + val entries: Map get() = entriesByKey + 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(entries.size <= REMOTE_CONFIG_MAX_KEYS) + require(entries.map { it.key }.distinct().size == entries.size) + 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 && entriesByKey.size == other.entriesByKey.size && + entriesByKey.all { (key, entry) -> entry.contentEquals(other.entriesByKey[key]) } +} + +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, +) { + init { + require(active != null || previous == null) + require(didActivate || (active == null && previous == null)) + require(candidate == null || active == null || candidate.releaseNumber >= active.releaseNumber) + require( + candidate == null || active == null || candidate.releaseNumber != active.releaseNumber || + candidate.contentEquals(active), + ) + require(previous == null || active == null || previous.releaseNumber < active.releaseNumber) + } +} + +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() +} + +internal class RemoteConfigSnapshot( + private val primaryRelease: RemoteConfigSnapshotRelease?, + private val previousRelease: RemoteConfigSnapshotRelease?, + private val bundledRelease: RemoteConfigSnapshotRelease?, +) { + 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) + }, + ) + + @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 } + 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 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, + ) +} + +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..2365acfdc --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCore.kt @@ -0,0 +1,290 @@ +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 + +internal enum class RemoteConfigSnapshotTransitionStatus { + Accepted, + Activated, + Ignored, + PersistenceFailed, + Unchanged, +} + +internal data class RemoteConfigSnapshotTransitionResult( + val status: RemoteConfigSnapshotTransitionStatus, + val changed: Boolean = false, + val update: RemoteConfigSnapshotUpdate? = null, +) + +internal class RemoteConfigSnapshotCore( + private val store: RemoteConfigSnapshotStore, + private val bundledRelease: RemoteConfigScopedBundledRelease?, +) { + private val lock = Any() + private val deliveryLock = Any() + private var currentScope: RemoteConfigSnapshotScope? = null + private var state = RemoteConfigSnapshotState() + private var scopeLoadFailed = false + private var scopeGeneration = 0L + private var nextObserverToken = 0L + private val observers = linkedMapOf Unit>() + private val pendingDeliveries = ArrayDeque() + private var isDrainingDeliveries = false + + fun setScope(scope: RemoteConfigSnapshotScope?) { + synchronized(deliveryLock) { + synchronized(lock) { + if (currentScope == scope && !(scope != null && scopeLoadFailed)) return + if (currentScope != scope) { + currentScope = scope + state = RemoteConfigSnapshotState() + scopeLoadFailed = false + scopeGeneration++ + } + scope?.let(::loadScopeState) + } + } + } + + 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 addUpdateObserver(observer: (RemoteConfigSnapshotUpdate) -> Unit): Long = synchronized(lock) { + val token = ++nextObserverToken + observers[token] = observer + token + } + + fun removeUpdateObserver(token: Long) { + synchronized(lock) { observers.remove(token) } + } + + fun acceptCandidate( + scope: RemoteConfigSnapshotScope, + release: RemoteConfigSnapshotRelease, + ): RemoteConfigSnapshotTransitionResult { + val delivery = synchronized(lock) { + if (scope != currentScope) return@synchronized TransitionDelivery.ignored() + if (!ensureCurrentScopeLoaded()) return@synchronized TransitionDelivery.persistenceFailed() + val latestNumber = maxOf( + state.candidate?.releaseNumber ?: 0, + state.active?.releaseNumber ?: 0, + ) + if (release.releaseNumber <= latestNumber) return@synchronized TransitionDelivery.ignored() + + val oldSnapshot = snapshotFor(state.active, state.previous) + val nextState = if (release.containsImmediateEntry) { + RemoteConfigSnapshotState( + candidate = release, + active = release, + previous = state.active, + didActivate = true, + ) + } else { + state.copy(candidate = release) + } + if (!saveCurrentScope(nextState)) return@synchronized TransitionDelivery.persistenceFailed() + state = nextState + if (release.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, + ).also(::enqueueDeliveryLocked) + } else { + TransitionDelivery( + RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.Accepted), + ) + } + } + drainDeliveries() + return delivery.result + } + + fun activate(): RemoteConfigSnapshotTransitionResult { + val delivery = synchronized(lock) { + 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 + 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, + ) + if (!saveCurrentScope(nextState)) return@synchronized TransitionDelivery.persistenceFailed() + state = nextState + 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) + scopeLoadFailed = false + } + RemoteConfigSnapshotLoadStatus.Missing -> { + state = RemoteConfigSnapshotState() + scopeLoadFailed = false + } + RemoteConfigSnapshotLoadStatus.Failed -> { + 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(primary, previous, bundledRelease?.releaseFor(currentScope)) + + 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 fun drainDeliveries() { + synchronized(deliveryLock) { + if (isDrainingDeliveries) return + isDrainingDeliveries = true + try { + while (true) { + val delivery = synchronized(lock) { pendingDeliveries.pollFirst() } ?: break + deliverIfCurrent(delivery) + } + } finally { + isDrainingDeliveries = false + } + } + } + + 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 = + contentEquals(other) + + private data class TransitionDelivery( + val result: RemoteConfigSnapshotTransitionResult, + val update: RemoteConfigSnapshotUpdate? = null, + val observers: List<(RemoteConfigSnapshotUpdate) -> Unit> = emptyList(), + val scopeGeneration: Long? = null, + ) { + companion object { + fun ignored() = TransitionDelivery( + RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.Ignored), + ) + + fun persistenceFailed() = TransitionDelivery( + RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.PersistenceFailed), + ) + + 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, + ) + } + } +} 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 index af2b55696..eb14aac5b 100644 --- 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 @@ -224,6 +224,7 @@ internal class BundledRemoteConfigDefaultsDocument( 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) @@ -269,6 +270,8 @@ private fun parsePortableJson(bytes: ByteArray): ParsedJson? = try { null } +internal fun isPortableRemoteConfigJson(bytes: ByteArray): Boolean = parsePortableJson(bytes) != null + @Suppress("ComplexMethod") private fun JsonReader.readPortableJsonValue(depth: Int): Any? = when (peek()) { JsonReader.Token.BEGIN_ARRAY -> { 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..eb78e15d6 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStore.kt @@ -0,0 +1,515 @@ +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.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 = 1 +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, +} + +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 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 { + loadTrusted(scope)?.let { state -> + RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Found, state) + } ?: RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Missing) + } catch (_: Exception) { + RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Failed) + } + + @Suppress("ReturnCount") + private fun loadTrusted(scope: RemoteConfigSnapshotScope): RemoteConfigSnapshotState? { + val storageKey = remoteConfigSnapshotStorageKey(scope) + val index = loadIndex(clearInvalid = true) + val rawEnvelope = cache.getString(storageKey, null) + val envelopeBytes = rawEnvelope?.toByteArray(Charsets.UTF_8)?.size + val envelope = rawEnvelope + ?.takeIf { + requireNotNull(envelopeBytes) <= maxStateBytes && envelopeBytes <= maxTotalBytes + } + ?.let(::decodeEnvelope) + val decoded = envelope + ?.takeIf { it.matches(scope, storageKey) } + ?.state + ?.toDecodedModel() + if (decoded != null) { + val rewritten = decoded.requiresRewrite && save(scope, decoded.state) + if (!rewritten) { + if (storageKey in index.storageKeys) { + promoteAfterRead(storageKey, index) + } else { + admitRecoveredAfterRead(storageKey, requireNotNull(envelopeBytes), index) + } + } + return decoded.state + } + removeInvalidEnvelope(storageKey, index) + return null + } + + @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() + 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, + ) +} + +@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 PersistedRemoteConfigSnapshotState( + val candidate: PersistedRemoteConfigSnapshotRelease?, + val active: PersistedRemoteConfigSnapshotRelease?, + val previous: PersistedRemoteConfigSnapshotRelease?, + val didActivate: Boolean, +) + +@JsonClass(generateAdapter = true) +internal data class PersistedRemoteConfigSnapshotRelease( + val releaseUid: String, + val releaseNumber: Long, + val manifestContentHash: String, + val entries: List, +) + +@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("ComplexMethod") +private fun PersistedRemoteConfigSnapshotState.toDecodedModel(): DecodedRemoteConfigSnapshotState { + var candidateModel = candidate?.toModel() + var activeModel = active?.toModel() + var previousModel = previous?.toModel() + var requiresRewrite = + (candidate != null && candidateModel == null) || + (active != null && activeModel == null) || + (previous != null && previousModel == null) + + if (!didActivate && activeModel != null) { + activeModel = null + previousModel = null + requiresRewrite = true + } + if (activeModel == null && previousModel != null) { + previousModel = null + requiresRewrite = true + } + if (candidateModel != null && activeModel != null && + candidateModel.releaseNumber < activeModel.releaseNumber + ) { + candidateModel = null + requiresRewrite = true + } + if (candidateModel != null && activeModel != null && + candidateModel.releaseNumber == activeModel.releaseNumber + ) { + if (candidateModel.contentEquals(activeModel)) { + candidateModel = activeModel + } else { + candidateModel = null + requiresRewrite = true + } + } + if (previousModel != null && activeModel != null && + previousModel.releaseNumber >= activeModel.releaseNumber + ) { + previousModel = null + requiresRewrite = true + } + return DecodedRemoteConfigSnapshotState( + state = RemoteConfigSnapshotState( + candidate = candidateModel, + active = activeModel, + previous = previousModel, + didActivate = didActivate, + ), + requiresRewrite = requiresRewrite, + ) +} + +@Suppress("ReturnCount") +private fun PersistedRemoteConfigSnapshotRelease.toModel(): RemoteConfigSnapshotRelease? { + return try { + RemoteConfigSnapshotRelease( + releaseUid = releaseUid, + releaseNumber = releaseNumber, + manifestContentHash = manifestContentHash, + entries = entries.map { it.toModel() ?: return null }, + ) + } 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( + candidate = candidate?.toPersisted(), + active = active?.toPersisted(), + previous = previous?.toPersisted(), + didActivate = didActivate, +) + +private fun RemoteConfigSnapshotRelease.toPersisted() = 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(), + ) + }, +) + +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() + .joinToString(separator = "") { byte -> + val value = byte.toInt() and BYTE_MASK + "${HEX[value ushr NIBBLE_SHIFT]}${HEX[value and LOW_NIBBLE_MASK]}" + } + return "$REMOTE_CONFIG_SNAPSHOT_STORAGE_PREFIX$digest" +} 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..85df1514c --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCoreTest.kt @@ -0,0 +1,430 @@ +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 + +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 `older equal and conflicting replays cannot replace freshest candidate`() { + core.setScope(scopeA) + core.acceptCandidate(scopeA, release("newest", 3, mapOf("a" to "3"))) + + for (stale in listOf( + release("older", 2, mapOf("a" to "2"), immediateKey = "a"), + release("conflict", 3, mapOf("a" to "\"conflict\""), immediateKey = "a"), + )) { + assertEquals( + RemoteConfigSnapshotTransitionStatus.Ignored, + core.acceptCandidate(scopeA, stale).status, + ) + } + assertEquals("newest", core.lastFetchedSnapshot()?.releaseUid) + assertEquals(RemoteConfigSnapshotTransitionStatus.Activated, core.activate().status) + assertEquals("3", core.currentSnapshot().rawValue("a")?.value?.decodeToString()) + } + + @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) + } + + 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 + } + } +} 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/storage/PersistentRemoteConfigSnapshotStoreTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStoreTest.kt new file mode 100644 index 000000000..432f05a50 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStoreTest.kt @@ -0,0 +1,447 @@ +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.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.assertFalse +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 `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\"]}", + ) + + assertNull(store().loadState(userA)) + + 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 = 600) + 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 `equal number conflicting candidate is dropped and canonical active survives`() { + val store = store() + val storageKey = remoteConfigSnapshotStorageKey(userA) + val active = release("two", 2, "active") + assertTrue( + store.save( + userA, + RemoteConfigSnapshotState( + candidate = active, + active = active, + previous = release("one", 1, "previous"), + didActivate = true, + ), + ), + ) + cache.strings[storageKey] = requireNotNull(cache.strings[storageKey]).replaceFirst( + "\"variationUid\":\"variation-two\"", + "\"variationUid\":\"variation-conflict\"", + ) + + val salvaged = store.loadState(userA) + + assertNull(salvaged?.candidate) + assertEquals("two", salvaged?.active?.releaseUid) + assertEquals("variation-two", salvaged?.active?.entry("key")?.variationUid) + assertNull(store().loadState(userA)?.candidate) + } + + @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) = RemoteConfigSnapshotRelease( + releaseUid = uid, + releaseNumber = number, + manifestContentHash = "a".repeat(64), + entries = listOf( + RemoteConfigSnapshotEntry.value( + key = "key", + rawValue = "\"$value\"".encodeToByteArray(), + variationUid = "variation-$uid", + applyPolicy = RemoteConfigSnapshotApplyPolicy.OnNextActivate, + metadata = null, + ), + ), + ) + + 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" + } +} From bcb1c90e1766141969b4a545cc85228877b17a35 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Thu, 6 Aug 2026 04:00:18 +0300 Subject: [PATCH 09/30] feat(remote-config): validate and durably admit snapshots --- .../remoteconfig/RemoteConfigSnapshot.kt | 107 +++- .../remoteconfig/RemoteConfigSnapshotCore.kt | 222 ++++++- .../RemoteConfigSnapshotEnvelopeParser.kt | 552 ++++++++++++++++++ .../PersistentRemoteConfigSnapshotStore.kt | 208 +++++-- .../RemoteConfigSnapshotCoreTest.kt | 482 ++++++++++++++- .../RemoteConfigSnapshotEnvelopeParserTest.kt | 255 ++++++++ ...PersistentRemoteConfigSnapshotStoreTest.kt | 318 +++++++++- .../remoteconfigv2/resolved-snapshot-v1.json | 1 + 8 files changed, 2070 insertions(+), 75 deletions(-) create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParser.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParserTest.kt create mode 100644 sdk/src/test/resources/remoteconfigv2/resolved-snapshot-v1.json 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 index 0d73508f6..411b37219 100644 --- 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 @@ -3,6 +3,8 @@ 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 @@ -120,10 +122,20 @@ internal class RemoteConfigSnapshotRelease( 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() + 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 } @@ -131,8 +143,16 @@ internal class RemoteConfigSnapshotRelease( require(releaseUid.isNotEmpty() && releaseUid.hasValidUidLength() && releaseUid.hasValidSurrogatePairs()) require(releaseNumber in 1..PORTABLE_JSON_MAX_INTEGER) require(LOWERCASE_SHA256_PATTERN.matches(manifestContentHash)) - require(entries.size <= REMOTE_CONFIG_MAX_KEYS) + 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) @@ -143,8 +163,44 @@ internal class RemoteConfigSnapshotRelease( internal fun contentEquals(other: RemoteConfigSnapshotRelease?): Boolean = other != null && releaseUid == other.releaseUid && releaseNumber == other.releaseNumber && - manifestContentHash == other.manifestContentHash && entriesByKey.size == other.entriesByKey.size && + 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( @@ -168,19 +224,60 @@ internal data class RemoteConfigSnapshotState( 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(candidate == null || active == null || candidate.releaseNumber >= active.releaseNumber) + require(latestAdmissionToken >= highestSlotToken) + require(candidate == null || active == null || candidate.admissionToken >= active.admissionToken) require( - candidate == null || active == null || candidate.releaseNumber != active.releaseNumber || + candidate == null || active == null || candidate.admissionToken != active.admissionToken || candidate.contentEquals(active), ) - require(previous == null || active == null || previous.releaseNumber < active.releaseNumber) + 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, 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 index 2365acfdc..d30b4bf07 100644 --- 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 @@ -4,12 +4,14 @@ import com.qonversion.android.sdk.internal.storage.RemoteConfigSnapshotLoadResul import com.qonversion.android.sdk.internal.storage.RemoteConfigSnapshotLoadStatus import com.qonversion.android.sdk.internal.storage.RemoteConfigSnapshotStore import java.util.ArrayDeque +import java.util.UUID internal enum class RemoteConfigSnapshotTransitionStatus { Accepted, Activated, Ignored, PersistenceFailed, + Rejected, Unchanged, } @@ -19,16 +21,52 @@ internal data class RemoteConfigSnapshotTransitionResult( 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, + expectation: RemoteConfigSnapshotEnvelopeExpectation, + ) = RemoteConfigSnapshotAdmissionToken( + ownerNonce = ownerNonce, + admission = BoundRemoteConfigSnapshotAdmission( + ordinal = ordinal, + scope = scope, + scopeGeneration = scopeGeneration, + expectation = expectation, + ), + ) + } +} + +internal data class BoundRemoteConfigSnapshotAdmission( + val ordinal: Long, + val scope: RemoteConfigSnapshotScope, + val scopeGeneration: Long, + val expectation: RemoteConfigSnapshotEnvelopeExpectation, +) + internal class RemoteConfigSnapshotCore( private val store: RemoteConfigSnapshotStore, private val bundledRelease: RemoteConfigScopedBundledRelease?, + private val envelopeParser: RemoteConfigSnapshotEnvelopeDecoder = RemoteConfigSnapshotEnvelopeParser(), ) { private val lock = Any() private val deliveryLock = Any() + private val admissionOwnerNonce = UUID.randomUUID() private var currentScope: RemoteConfigSnapshotScope? = null private var state = RemoteConfigSnapshotState() private var scopeLoadFailed = false private var scopeGeneration = 0L + private var nextAdmissionToken = 0L private var nextObserverToken = 0L private val observers = linkedMapOf Unit>() private val pendingDeliveries = ArrayDeque() @@ -42,6 +80,7 @@ internal class RemoteConfigSnapshotCore( currentScope = scope state = RemoteConfigSnapshotState() scopeLoadFailed = false + nextAdmissionToken = 0L scopeGeneration++ } scope?.let(::loadScopeState) @@ -70,33 +109,151 @@ internal class RemoteConfigSnapshotCore( synchronized(lock) { observers.remove(token) } } + fun beginAdmission( + scope: RemoteConfigSnapshotScope, + expectation: RemoteConfigSnapshotEnvelopeExpectation, + ): RemoteConfigSnapshotAdmissionToken? = + synchronized(lock) { + if (expectation.environmentUid != scope.environment) return@synchronized null + val admission = issueAdmissionLocked(scope) ?: return@synchronized null + RemoteConfigSnapshotAdmissionToken.issue( + ownerNonce = admissionOwnerNonce, + ordinal = admission.ordinal, + scope = scope, + scopeGeneration = admission.scopeGeneration, + expectation = expectation, + ) + } + + 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, + ) + } + + @Suppress("ReturnCount") + fun admitCandidate( + admissionToken: RemoteConfigSnapshotAdmissionToken, + body: ByteArray, + etag: String, + ): 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 envelope = envelopeParser.parse(body, etag, admission.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) return@synchronized TransitionDelivery.ignored() + if (scope != currentScope || admissionScopeGeneration != scopeGeneration) { + return@synchronized if (authoritativeComplete) { + TransitionDelivery.rejected() + } else { + TransitionDelivery.ignored() + } + } if (!ensureCurrentScopeLoaded()) return@synchronized TransitionDelivery.persistenceFailed() - val latestNumber = maxOf( + 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 <= latestNumber) return@synchronized TransitionDelivery.ignored() + 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 (release.containsImmediateEntry) { + val nextState = if (admittedRelease.containsImmediateEntry) { RemoteConfigSnapshotState( - candidate = release, - active = release, + candidate = admittedRelease, + active = admittedRelease, previous = state.active, didActivate = true, + latestAdmissionToken = admissionOrdinal, ) } else { - state.copy(candidate = release) + state.copy(candidate = admittedRelease, latestAdmissionToken = admissionOrdinal) } if (!saveCurrentScope(nextState)) return@synchronized TransitionDelivery.persistenceFailed() state = nextState - if (release.containsImmediateEntry) { + if (admittedRelease.containsImmediateEntry) { val update = buildUpdate(oldSnapshot, snapshotFor(nextState.active, nextState.previous)) TransitionDelivery( result = RemoteConfigSnapshotTransitionResult( @@ -107,6 +264,7 @@ internal class RemoteConfigSnapshotCore( update = update, observers = observers.values.toList(), scopeGeneration = scopeGeneration, + admissionOrdinal = admissionOrdinal, ).also(::enqueueDeliveryLocked) } else { TransitionDelivery( @@ -144,6 +302,7 @@ internal class RemoteConfigSnapshotCore( active = candidate, previous = state.active, didActivate = true, + latestAdmissionToken = state.latestAdmissionToken, ) if (!saveCurrentScope(nextState)) return@synchronized TransitionDelivery.persistenceFailed() state = nextState @@ -171,10 +330,12 @@ internal class RemoteConfigSnapshotCore( when (result.status) { RemoteConfigSnapshotLoadStatus.Found -> { state = requireNotNull(result.state) + nextAdmissionToken = state.latestAdmissionToken scopeLoadFailed = false } RemoteConfigSnapshotLoadStatus.Missing -> { state = RemoteConfigSnapshotState() + nextAdmissionToken = 0L scopeLoadFailed = false } RemoteConfigSnapshotLoadStatus.Failed -> { @@ -226,7 +387,7 @@ internal class RemoteConfigSnapshotCore( isDrainingDeliveries = true try { while (true) { - val delivery = synchronized(lock) { pendingDeliveries.pollFirst() } ?: break + val delivery = synchronized(lock) { pollCurrentDeliveryLocked() } ?: break deliverIfCurrent(delivery) } } finally { @@ -235,6 +396,16 @@ internal class RemoteConfigSnapshotCore( } } + 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) @@ -250,13 +421,14 @@ internal class RemoteConfigSnapshotCore( } private fun RemoteConfigSnapshotRelease.isSameRelease(other: RemoteConfigSnapshotRelease?): Boolean = - contentEquals(other) + 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( @@ -267,6 +439,10 @@ internal class RemoteConfigSnapshotCore( RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.PersistenceFailed), ) + fun rejected() = TransitionDelivery( + RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.Rejected), + ) + fun unchanged() = TransitionDelivery( RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.Unchanged), ) @@ -288,3 +464,29 @@ internal class RemoteConfigSnapshotCore( } } } + +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..b1e43fe3c --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParser.kt @@ -0,0 +1,552 @@ +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}$") + +internal data class RemoteConfigSnapshotEnvelopeExpectation( + val projectId: Long, + val environmentUid: String, + val contextFingerprint: 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 && + envelope.contextFingerprint == expectation.contextFingerprint + } + } + + @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() && + LOWERCASE_SHA256_PATTERN.matches(contextFingerprint) + +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/storage/PersistentRemoteConfigSnapshotStore.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStore.kt index eb78e15d6..9cabd79d0 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -15,7 +16,7 @@ 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 = 1 +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 @@ -88,7 +89,7 @@ internal class PersistentRemoteConfigSnapshotStore( val decoded = envelope ?.takeIf { it.matches(scope, storageKey) } ?.state - ?.toDecodedModel() + ?.toDecodedModel(scope.environment) if (decoded != null) { val rewritten = decoded.requiresRewrite && save(scope, decoded.state) if (!rewritten) { @@ -214,7 +215,7 @@ internal class PersistentRemoteConfigSnapshotStore( return null } if (!envelope.matches(scope, storageKey)) return null - val decoded = envelope.state.toDecodedModel() + val decoded = envelope.state.toDecodedModel(scope.environment) if (decoded.requiresRewrite && decoded.state == RemoteConfigSnapshotState()) return null return rawBytes } @@ -356,6 +357,8 @@ internal data class PersistedRemoteConfigSnapshotState( val active: PersistedRemoteConfigSnapshotRelease?, val previous: PersistedRemoteConfigSnapshotRelease?, val didActivate: Boolean, + val latestAdmissionToken: Long, + val stateDigest: String, ) @JsonClass(generateAdapter = true) @@ -364,6 +367,11 @@ internal data class PersistedRemoteConfigSnapshotRelease( 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) @@ -380,17 +388,40 @@ private data class DecodedRemoteConfigSnapshotState( val requiresRewrite: Boolean, ) -@Suppress("ComplexMethod") -private fun PersistedRemoteConfigSnapshotState.toDecodedModel(): DecodedRemoteConfigSnapshotState { - var candidateModel = candidate?.toModel() - var activeModel = active?.toModel() - var previousModel = previous?.toModel() +@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 = - (candidate != null && candidateModel == null) || + !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 (!didActivate && activeModel != null) { + if (!normalizedDidActivate && activeModel != null) { activeModel = null previousModel = null requiresRewrite = true @@ -400,47 +431,93 @@ private fun PersistedRemoteConfigSnapshotState.toDecodedModel(): DecodedRemoteCo requiresRewrite = true } if (candidateModel != null && activeModel != null && - candidateModel.releaseNumber < activeModel.releaseNumber + candidateModel.admissionToken < activeModel.admissionToken ) { - candidateModel = null + if (candidateModel.canonicalBodyBytes != null) { + activeModel = null + previousModel = null + } else { + candidateModel = null + } requiresRewrite = true } if (candidateModel != null && activeModel != null && - candidateModel.releaseNumber == activeModel.releaseNumber + candidateModel.admissionToken == activeModel.admissionToken ) { - if (candidateModel.contentEquals(activeModel)) { - candidateModel = activeModel - } else { - candidateModel = null - requiresRewrite = true - } + if (!candidateModel.contentEquals(activeModel)) requiresRewrite = true + activeModel = candidateModel } if (previousModel != null && activeModel != null && - previousModel.releaseNumber >= activeModel.releaseNumber + 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 = didActivate, + didActivate = normalizedDidActivate, + latestAdmissionToken = normalizedLatestAdmissionToken, ), requiresRewrite = requiresRewrite, ) } -@Suppress("ReturnCount") -private fun PersistedRemoteConfigSnapshotRelease.toModel(): RemoteConfigSnapshotRelease? { +@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 = entries.map { it.toModel() ?: return null }, - ) + entries = decodedEntries, + canonicalBody = canonicalBody, + strongETag = strongETag, + contextFingerprint = contextFingerprint, + admissionToken = admissionToken, + ).takeIf { it.contentDigest == contentDigest } } catch (_: IllegalArgumentException) { null } @@ -474,14 +551,29 @@ private fun String?.decodeCanonicalBase64(): ByteArray? { return decoded.takeIf { it.base64() == this }?.toByteArray() } -private fun RemoteConfigSnapshotState.toPersisted() = PersistedRemoteConfigSnapshotState( - candidate = candidate?.toPersisted(), - active = active?.toPersisted(), - previous = previous?.toPersisted(), - didActivate = didActivate, -) +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() = PersistedRemoteConfigSnapshotRelease( +private fun RemoteConfigSnapshotRelease.toPersisted( + includeTransportEvidence: Boolean, +) = PersistedRemoteConfigSnapshotRelease( releaseUid = releaseUid, releaseNumber = releaseNumber, manifestContentHash = manifestContentHash, @@ -497,8 +589,49 @@ private fun RemoteConfigSnapshotRelease.toPersisted() = PersistedRemoteConfigSna 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 -> @@ -506,10 +639,11 @@ internal fun remoteConfigSnapshotStorageKey(scope: RemoteConfigSnapshotScope): S messageDigest.update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(bytes.size).array()) messageDigest.update(bytes) } - val digest = messageDigest.digest() - .joinToString(separator = "") { byte -> - val value = byte.toInt() and BYTE_MASK - "${HEX[value ushr NIBBLE_SHIFT]}${HEX[value and LOW_NIBBLE_MASK]}" - } + 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/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCoreTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCoreTest.kt index 85df1514c..12ffeb190 100644 --- 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 @@ -12,6 +12,8 @@ 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") @@ -133,22 +135,27 @@ internal class RemoteConfigSnapshotCoreTest { } @Test - fun `older equal and conflicting replays cannot replace freshest candidate`() { + 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"))) - for (stale in listOf( - release("older", 2, mapOf("a" to "2"), immediateKey = "a"), - release("conflict", 3, mapOf("a" to "\"conflict\""), immediateKey = "a"), - )) { - assertEquals( - RemoteConfigSnapshotTransitionStatus.Ignored, - core.acceptCandidate(scopeA, stale).status, - ) - } + 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.activate().status) - assertEquals("3", core.currentSnapshot().rawValue("a")?.value?.decodeToString()) + + 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 @@ -373,6 +380,457 @@ internal class RemoteConfigSnapshotCoreTest { assertEquals("two", core.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.admitCandidate( + admissionToken = requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + 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 environment and context scope`() { + core.setScope(scopeA) + val body = wireBody("wire", 1, "\"a\":${wireItem("1")}").encodeToByteArray() + assertNull(core.beginAdmission(scopeB, wireExpectation())) + assertNull(core.beginAdmission(scopeA, wireExpectation().copy(environmentUid = "staging"))) + for (expectation in listOf( + wireExpectation().copy(projectId = 43), + wireExpectation().copy(contextFingerprint = "b".repeat(64)), + )) { + val token = requireNotNull(core.beginAdmission(scopeA, expectation)) + val result = core.admitCandidate(token, body, strongETag(body)) + assertEquals(RemoteConfigSnapshotTransitionStatus.Rejected, result.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.admitCandidate( + requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + 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, expectation)) + val result = AtomicReference() + val admissionThread = Thread { + result.set(raceCore.admitCandidate(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 carries its original expectation`() { + 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, wireExpectation())) + val validBody = wireBody("wire-a", 7, "\"a\":${wireItem("1")}").encodeToByteArray() + + assertEquals( + RemoteConfigSnapshotTransitionStatus.Rejected, + secondCore.admitCandidate(token, validBody, strongETag(validBody)).status, + ) + assertTrue(secondStore.savedStates.isEmpty()) + + val swappedContextBody = wireBody( + "wire-b", + 7, + "\"a\":${wireItem("2")}", + contextFingerprint = "b".repeat(64), + ).encodeToByteArray() + assertEquals( + RemoteConfigSnapshotTransitionStatus.Rejected, + firstCore.admitCandidate(token, swappedContextBody, strongETag(swappedContextBody)).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, expectation)) + val result = AtomicReference() + val admissionThread = Thread { + result.set(raceCore.admitCandidate(superseded, immediateBody, strongETag(immediateBody))) + } + admissionThread.start() + assertTrue(parserStarted.await(2, TimeUnit.SECONDS)) + + requireNotNull(raceCore.beginAdmission(scopeA, expectation)) + 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 expectation = wireExpectation() + val supersededToken = requireNotNull(raceCore.beginAdmission(scopeA, expectation)) + val supersededResult = AtomicReference() + val supersededThread = Thread { + supersededResult.set( + raceCore.admitCandidate( + supersededToken, + supersededBody, + strongETag(supersededBody), + ), + ) + } + supersededThread.start() + assertTrue(supersededCommitFinished.await(2, TimeUnit.SECONDS)) + + requireNotNull(raceCore.beginAdmission(scopeA, expectation)) + 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.admitCandidate( + requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + current, + strongETag(current), + ).status, + ) + val rollback = wireBody("release-six", 6, "\"a\":${wireItem("6")}").encodeToByteArray() + + assertEquals( + RemoteConfigSnapshotTransitionStatus.Rejected, + core.admitCandidate( + requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + 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.admitCandidate( + requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + secondRollback, + strongETag(secondRollback), + ).status, + ) + assertEquals("release-seven", core.currentSnapshot().releaseUid) + } + + @Test + fun `same server release can be sequentially admitted for different contexts`() { + 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.admitCandidate( + requireNotNull(core.beginAdmission(scopeA, wireExpectation(firstContext))), + first, + strongETag(first), + ).status, + ) + core.activate() + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + core.admitCandidate( + requireNotNull(core.beginAdmission(scopeA, wireExpectation(secondContext))), + 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, wireExpectation())) + val newToken = requireNotNull(core.beginAdmission(scopeA, wireExpectation())) + val oldBody = wireBody("old", 7, "\"a\":${wireItem("1")}").encodeToByteArray() + val newBody = wireBody("new", 7, "\"a\":${wireItem("2")}").encodeToByteArray() + + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + core.admitCandidate(newToken, newBody, strongETag(newBody)).status, + ) + assertEquals( + RemoteConfigSnapshotTransitionStatus.Rejected, + core.admitCandidate(oldToken, oldBody, strongETag(oldBody)).status, + ) + assertEquals("new", core.lastFetchedSnapshot()?.releaseUid) + + val laterToken = requireNotNull(core.beginAdmission(scopeA, wireExpectation())) + val laterBody = wireBody("later", 7, "\"a\":${wireItem("3")}").encodeToByteArray() + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + core.admitCandidate(laterToken, laterBody, strongETag(laterBody)).status, + ) + assertEquals("later", core.lastFetchedSnapshot()?.releaseUid) + + val orderedCore = RemoteConfigSnapshotCore(store, bundled) + orderedCore.setScope(scopeB) + val orderedOldToken = requireNotNull(orderedCore.beginAdmission(scopeB, wireExpectation())) + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + orderedCore.admitCandidate( + orderedOldToken, + oldBody, + strongETag(oldBody), + ).status, + ) + val orderedNewToken = requireNotNull(orderedCore.beginAdmission(scopeB, wireExpectation())) + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + orderedCore.admitCandidate( + 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, wireExpectation())) + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + core.admitCandidate(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, wireExpectation())) + val restartedBody = wireBody("wire-restarted", 7, "\"a\":${wireItem("2")}").encodeToByteArray() + assertEquals( + RemoteConfigSnapshotTransitionStatus.Accepted, + restarted.admitCandidate(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.admitCandidate( + requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + first, + strongETag(first), + ).status, + ) + assertEquals( + RemoteConfigSnapshotTransitionStatus.Activated, + core.admitCandidate( + requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + 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(contextFingerprint: String = "a".repeat(64)) = + RemoteConfigSnapshotEnvelopeExpectation( + projectId = 42, + environmentUid = "production", + contextFingerprint = contextFingerprint, + ) + + private fun wireBody( + releaseUid: String, + releaseNumber: Long, + values: String, + contextFingerprint: String = "a".repeat(64), + ) = + "{\"schema_version\":1,\"project_id\":42,\"environment_uid\":\"production\"," + + "\"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, 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..f3f081af9 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParserTest.kt @@ -0,0 +1,255 @@ +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", + contextFingerprint = "a".repeat(64), + ) + + @Test + fun `cross-language canonical body and strong ETag are admitted byte exactly`() { + val body = requireNotNull(javaClass.getResourceAsStream("/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 environment and context fingerprint are exact admission boundaries`() { + val body = validBody() + val mismatches = listOf( + expectation.copy(projectId = 43), + expectation.copy(environmentUid = "env-staging"), + expectation.copy(contextFingerprint = "b".repeat(64)), + ) + + 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"))) + assertNull(parse(body.replace("a".repeat(64), "b".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 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/storage/PersistentRemoteConfigSnapshotStoreTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStoreTest.kt index 432f05a50..3600ffbf3 100644 --- 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 @@ -1,6 +1,9 @@ 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 @@ -8,7 +11,9 @@ import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshotStat 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 @@ -41,6 +46,42 @@ internal class PersistentRemoteConfigSnapshotStoreTest { 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") @@ -102,7 +143,7 @@ internal class PersistentRemoteConfigSnapshotStoreTest { @Test fun `oversized replacement is rejected before durable storage changes`() { - val store = PersistentRemoteConfigSnapshotStore(cache, moshi, maxStateBytes = 600) + 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 @@ -322,34 +363,232 @@ internal class PersistentRemoteConfigSnapshotStoreTest { } @Test - fun `equal number conflicting candidate is dropped and canonical active survives`() { + fun `verified candidate repairs a mutated active from the same local generation`() { val store = store() val storageKey = remoteConfigSnapshotStorageKey(userA) - val active = release("two", 2, "active") + 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, - previous = release("one", 1, "previous"), didActivate = true, ), ), ) - cache.strings[storageKey] = requireNotNull(cache.strings[storageKey]).replaceFirst( - "\"variationUid\":\"variation-two\"", - "\"variationUid\":\"variation-conflict\"", + cache.strings[storageKey] = requireNotNull(cache.strings[storageKey]).replaceLastOccurrence( + "\"rawBase64\":\"dHJ1ZQ==\"", + "\"rawBase64\":\"ZmFsc2U=\"", + ).replaceLastOccurrence( + "\"contentDigest\":\"${active.contentDigest}\"", + "\"contentDigest\":\"${mutatedActive.contentDigest}\"", ) val salvaged = store.loadState(userA) - assertNull(salvaged?.candidate) - assertEquals("two", salvaged?.active?.releaseUid) - assertEquals("variation-two", salvaged?.active?.entry("key")?.variationUid) + 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, + RemoteConfigSnapshotEnvelopeExpectation( + projectId = 42, + environmentUid = "production", + contextFingerprint = "b".repeat(64), + ), + ), + ) + } + + @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 `global persisted byte budget evicts least recently used envelopes`() { val probe = store() @@ -384,10 +623,17 @@ internal class PersistentRemoteConfigSnapshotStoreTest { scope: RemoteConfigSnapshotScope, ): RemoteConfigSnapshotState? = load(scope).state - private fun release(uid: String, number: Long, value: String) = RemoteConfigSnapshotRelease( + 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", @@ -399,6 +645,56 @@ internal class PersistentRemoteConfigSnapshotStoreTest { ), ) + 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", + contextFingerprint = "b".repeat(64), + ), + ), + ).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) 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}}}} From 1816a76cff8abc805af194b64c2d83d8a3f517b8 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Thu, 6 Aug 2026 05:21:21 +0300 Subject: [PATCH 10/30] feat(remote-config): add resilient fetch policy --- .../PersistentRemoteConfigFetchPolicyStore.kt | 110 +++ .../RemoteConfigFetchCoordinator.kt | 623 ++++++++++++++++ .../remoteconfig/RemoteConfigSnapshot.kt | 1 + .../remoteconfig/RemoteConfigSnapshotCore.kt | 25 + ...sistentRemoteConfigFetchPolicyStoreTest.kt | 82 ++ .../RemoteConfigFetchCoordinatorTest.kt | 703 ++++++++++++++++++ 6 files changed, 1544 insertions(+) create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigFetchPolicyStore.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinator.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigFetchPolicyStoreTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinatorTest.kt 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/RemoteConfigFetchCoordinator.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinator.kt new file mode 100644 index 000000000..034e40924 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinator.kt @@ -0,0 +1,623 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import java.util.ArrayDeque + +internal data class RemoteConfigFetchBinding( + val scope: RemoteConfigSnapshotScope, + val expectation: RemoteConfigSnapshotEnvelopeExpectation, +) { + init { + require(scope.environment == expectation.environmentUid) + } +} + +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 { + data class Success(val body: ByteArray, val etag: String) : RemoteConfigFetchResponse() + data class NotModified(val etag: String? = null) : RemoteConfigFetchResponse() + data class Failure( + val statusCode: Int? = null, + val retryAfterMillis: Long? = null, + ) : 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() +} + +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 binding: RemoteConfigFetchBinding? = null + private var operationGeneration = 0L + private var inFlight: InFlight? = null + private var policyState = RemoteConfigFetchPolicyState() + + fun transitionTo(nextBinding: RemoteConfigFetchBinding?) { + val persistenceFailure = synchronized(operationLock) { + synchronized(lock) { + operationGeneration = nextGeneration(operationGeneration) + binding = nextBinding + core.setScope(nextBinding?.scope) + val loaded = nextBinding?.let { + loadPolicyState(RemoteConfigFetchPolicyScope.from(it.scope)) + } ?: 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 currentBinding = binding + ?: return FetchDecision.immediate(operationGeneration, RemoteConfigFetchResult.Superseded) + fetchGateLocked(forceReason, nowMillis())?.let { gate -> + return FetchDecision.immediate(operationGeneration, gate) + } + val admission = core.beginAdmission(currentBinding.scope, currentBinding.expectation) + ?: return FetchDecision.immediate( + operationGeneration, + RemoteConfigFetchResult.Failed(statusCode = null), + ) + val operation = InFlight( + generation = operationGeneration, + binding = currentBinding, + 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.binding.scope, operation.binding.expectation) + ?: 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(operation.admission, response.body, response.etag) + 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() }, + ) + } + + 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 binding: RemoteConfigFetchBinding, + var admission: RemoteConfigSnapshotAdmissionToken, + val waiters: MutableList, + var conditionalValidator: RemoteConfigConditionalRequestValidator?, + var attemptOrdinal: Long = 0, + var didRetryWithoutETag: Boolean = false, + ) { + val policyScope: RemoteConfigFetchPolicyScope = RemoteConfigFetchPolicyScope.from(binding.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/RemoteConfigSnapshot.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshot.kt index 411b37219..0b251a053 100644 --- 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 @@ -132,6 +132,7 @@ internal class RemoteConfigSnapshotRelease( 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() 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 index d30b4bf07..acf61d5e3 100644 --- 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 @@ -54,6 +54,12 @@ internal data class BoundRemoteConfigSnapshotAdmission( val expectation: RemoteConfigSnapshotEnvelopeExpectation, ) +internal data class RemoteConfigConditionalRequestValidator( + val etag: String, + val headAdmissionToken: Long, + val headContentDigest: String, +) + internal class RemoteConfigSnapshotCore( private val store: RemoteConfigSnapshotStore, private val bundledRelease: RemoteConfigScopedBundledRelease?, @@ -99,6 +105,25 @@ internal class RemoteConfigSnapshotCore( } } + 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 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/RemoteConfigFetchCoordinatorTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinatorTest.kt new file mode 100644 index 000000000..e891c009a --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinatorTest.kt @@ -0,0 +1,703 @@ +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") + private val binding = RemoteConfigFetchBinding( + scope = scope, + expectation = RemoteConfigSnapshotEnvelopeExpectation( + projectId = 42, + environmentUid = "production", + contextFingerprint = "a".repeat(64), + ), + ) + + @Test + fun `concurrent fetches coalesce into one transport request`() { + val transport = RecordingTransport() + val coordinator = coordinator(transport) + coordinator.transitionTo(binding) + 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(binding) + 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(binding) + 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(binding) + 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(binding) + coordinator.fetch(callback = {}) + transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 429, retryAfterMillis = 4_000)) + + coordinator.transitionTo( + binding.copy( + scope = RemoteConfigSnapshotScope("project", "production", "identified-user"), + expectation = binding.expectation.copy(contextFingerprint = "b".repeat(64)), + ), + ) + 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(binding) + 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(binding) + 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(binding) + 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(binding) + 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(binding) + 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(binding) + 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(binding) + 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(binding) + 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(binding) + val oldResults = mutableListOf() + coordinator.fetch(callback = oldResults::add) + + val nextBinding = binding.copy( + scope = RemoteConfigSnapshotScope("project", "production", "canonical-user-next"), + expectation = binding.expectation.copy(contextFingerprint = "b".repeat(64)), + ) + coordinator.transitionTo(nextBinding) + 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) + transport.complete(success("wrong-context", 1)) + val transition = (nextResults.single() as RemoteConfigFetchResult.Fetched).transition + assertEquals(RemoteConfigSnapshotTransitionStatus.Rejected, transition.status) + assertEquals(null, core.lastFetchedSnapshot()) + } + + @Test + fun `same visible binding 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(binding) + val results = mutableListOf() + coordinator.fetch(callback = results::add) + + coordinator.transitionTo(binding) + 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(2, 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(binding) + val events = Collections.synchronizedList(mutableListOf()) + coordinator.fetch { events += "callback" } + + val responseThread = Thread { transport.complete(success("admitted", 1)) } + responseThread.start() + assertTrue(parserStarted.await(2, TimeUnit.SECONDS)) + val transitionFinished = CountDownLatch(1) + val transitionThread = Thread { + coordinator.transitionTo(null) + events += "transition" + transitionFinished.countDown() + } + transitionThread.start() + + assertFalse(transitionFinished.await(100, TimeUnit.MILLISECONDS)) + releaseParser.countDown() + responseThread.join(2_000) + transitionThread.join(2_000) + assertEquals(listOf("callback", "transition"), events) + } + + @Test + fun `one throwing coalesced callback cannot starve the remaining waiters`() { + val transport = RecordingTransport() + val coordinator = coordinator(transport) + coordinator.transitionTo(binding) + 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(binding) + 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(binding) + 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(binding) + 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(binding) + 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(binding) + 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(binding) + 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(binding) + 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(binding) + 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): RemoteConfigFetchResponse.Success { + val body = wireBody(uid, number).encodeToByteArray() + return RemoteConfigFetchResponse.Success(body, strongETag(body)) + } + + 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 + } + } +} From be6625736196605e436ba15017a2d3f7bcd4e8d2 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Thu, 6 Aug 2026 08:28:59 +0300 Subject: [PATCH 11/30] feat: guard remote config reads before activation --- .../remoteconfig/RemoteConfigReadGuard.kt | 396 +++++++++ .../remoteconfig/RemoteConfigSnapshotCore.kt | 259 +++++- .../PersistentRemoteConfigSnapshotStore.kt | 148 +++- .../remoteconfig/RemoteConfigReadGuardTest.kt | 767 ++++++++++++++++++ .../RemoteConfigSnapshotCoreTest.kt | 30 + ...PersistentRemoteConfigSnapshotStoreTest.kt | 23 +- 6 files changed, 1602 insertions(+), 21 deletions(-) create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigReadGuard.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigReadGuardTest.kt 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/RemoteConfigSnapshotCore.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCore.kt index acf61d5e3..105120753 100644 --- 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 @@ -5,6 +5,8 @@ import com.qonversion.android.sdk.internal.storage.RemoteConfigSnapshotLoadStatu 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, @@ -47,6 +49,37 @@ internal class RemoteConfigSnapshotAdmissionToken private constructor( } } +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, @@ -64,36 +97,170 @@ 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, ) { private val lock = Any() - private val deliveryLock = 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?) { - synchronized(deliveryLock) { + 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) } @@ -276,8 +443,21 @@ internal class RemoteConfigSnapshotCore( } else { state.copy(candidate = admittedRelease, latestAdmissionToken = admissionOrdinal) } - if (!saveCurrentScope(nextState)) return@synchronized TransitionDelivery.persistenceFailed() + 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( @@ -301,8 +481,16 @@ internal class RemoteConfigSnapshotCore( return delivery.result } - fun activate(): RemoteConfigSnapshotTransitionResult { + 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 @@ -312,6 +500,7 @@ internal class RemoteConfigSnapshotCore( 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) @@ -331,6 +520,7 @@ internal class RemoteConfigSnapshotCore( ) 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) @@ -355,17 +545,22 @@ internal class RemoteConfigSnapshotCore( 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 + } } } @@ -406,17 +601,63 @@ internal class RemoteConfigSnapshotCore( 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() { - synchronized(deliveryLock) { + deliveryLock.withLock { + while (scopeTransitionInProgress) deliveryBoundaryChanged.awaitUninterruptibly() if (isDrainingDeliveries) return isDrainingDeliveries = true - try { - while (true) { - val delivery = synchronized(lock) { pollCurrentDeliveryLocked() } ?: break + 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 { + } + } 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() } } } 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 index 9cabd79d0..84bdafcdd 100644 --- 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 @@ -33,6 +33,7 @@ internal enum class RemoteConfigSnapshotLoadStatus { Found, Missing, Failed, + Corrupt, } internal data class RemoteConfigSnapshotLoadResult( @@ -57,6 +58,8 @@ internal class PersistentRemoteConfigSnapshotStore( 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 { @@ -68,22 +71,30 @@ internal class PersistentRemoteConfigSnapshotStore( @Synchronized @Suppress("ReturnCount") override fun load(scope: RemoteConfigSnapshotScope): RemoteConfigSnapshotLoadResult = try { - loadTrusted(scope)?.let { state -> - RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Found, state) - } ?: RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Missing) + 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): RemoteConfigSnapshotState? { + private fun loadTrusted(scope: RemoteConfigSnapshotScope): TrustedSnapshotLoad { val storageKey = remoteConfigSnapshotStorageKey(scope) val index = loadIndex(clearInvalid = true) - val rawEnvelope = cache.getString(storageKey, null) - val envelopeBytes = rawEnvelope?.toByteArray(Charsets.UTF_8)?.size + val rawEnvelope = cache.getString(storageKey, null) ?: return TrustedSnapshotLoad.Missing + val envelopeBytes = rawEnvelope.toByteArray(Charsets.UTF_8).size val envelope = rawEnvelope - ?.takeIf { - requireNotNull(envelopeBytes) <= maxStateBytes && envelopeBytes <= maxTotalBytes + .takeIf { + envelopeBytes <= maxStateBytes && envelopeBytes <= maxTotalBytes } ?.let(::decodeEnvelope) val decoded = envelope @@ -96,13 +107,32 @@ internal class PersistentRemoteConfigSnapshotStore( if (storageKey in index.storageKeys) { promoteAfterRead(storageKey, index) } else { - admitRecoveredAfterRead(storageKey, requireNotNull(envelopeBytes), index) + admitRecoveredAfterRead(storageKey, envelopeBytes, index) } } - return decoded.state + return TrustedSnapshotLoad.Found(decoded.state) } removeInvalidEnvelope(storageKey, index) - return null + 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 @@ -334,6 +364,12 @@ internal class PersistentRemoteConfigSnapshotStore( 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) @@ -351,6 +387,33 @@ internal data class PersistedRemoteConfigSnapshotEnvelope( 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?, @@ -388,6 +451,69 @@ private data class DecodedRemoteConfigSnapshotState( 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, 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 index 12ffeb190..6b0e0c55c 100644 --- 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 @@ -380,6 +380,36 @@ internal class RemoteConfigSnapshotCoreTest { 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) 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 index 3600ffbf3..e6746b6a2 100644 --- 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 @@ -109,7 +109,9 @@ internal class PersistentRemoteConfigSnapshotStoreTest { "{\"version\":1,\"storageKeys\":[\"$storageKey\"]}", ) - assertNull(store().loadState(userA)) + 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)) @@ -589,6 +591,25 @@ internal class PersistentRemoteConfigSnapshotStoreTest { 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() From 6f2bdc993d29a1081a14cf05e4e21775397bca60 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Fri, 7 Aug 2026 11:28:06 +0300 Subject: [PATCH 12/30] feat(remote-config): bind the fetch policy to the v2 gateway Implement the internal Remote Config v2 transport adapter behind the existing RemoteConfigFetchTransport seam, so the resilient fetch policy can talk to the dark gateway routes without any public API change. - Bootstrap on a missing or expired session (POST /v3/remote-config-v2/session) and read the snapshot (POST /v3/remote-config-v2/snapshot) with the session header and the coordinator's exact If-None-Match validator. - A snapshot 401 drops the session and re-bootstraps exactly once; a second 401 is a typed failure, so the flow cannot loop. - The response body reaches durable admission as the exact bytes received, paired with the exact ETag: no decode, re-encode or charset round trip. - Sessions are stored per identity scope under a salted digest key, so an identity change addresses a different record and can never reuse the previous identity's token. Neither token is ever logged. - device_installed_at is sourced from PackageManager.firstInstallTime, a device fact that survives logout: the server takes min(device_installed_at, client.created_at), so a moving value would make a long-time user look new. Tests use MockWebServer (new test-only dependency, pinned to the SDK's OkHttp version) and cover the wire shape of both routes, byte equality on a non-canonical body, 304, 401 recovery and the no-loop bound, 404/503, session scoping, and the timeout path through the real coordinator. Claude-Session: https://claude.ai/code/session_018pHXqfbxkMQJFzUZ3jW4A8 --- sdk/build.gradle | 6 +- ...DeviceRemoteConfigClientContextProvider.kt | 65 +++ .../RemoteConfigGatewaySession.kt | 154 ++++++ .../RemoteConfigGatewayTransport.kt | 512 ++++++++++++++++++ ...ceRemoteConfigClientContextProviderTest.kt | 61 +++ .../PersistentRemoteConfigSessionStoreTest.kt | 108 ++++ ...teConfigGatewayTransportCoordinatorTest.kt | 262 +++++++++ .../RemoteConfigGatewayTransportTest.kt | 443 +++++++++++++++ 8 files changed, 1610 insertions(+), 1 deletion(-) create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/DeviceRemoteConfigClientContextProvider.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewaySession.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransport.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/DeviceRemoteConfigClientContextProviderTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigSessionStoreTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportCoordinatorTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportTest.kt diff --git a/sdk/build.gradle b/sdk/build.gradle index 1a0594a67..53dcee0bd 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -84,7 +84,8 @@ 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", + mockWebServer : "com.squareup.okhttp3:mockwebserver:$okhttp_version" ] lifecycle = [ @@ -164,6 +165,9 @@ dependencies { // Mockito testImplementation 'org.mockito:mockito-core:4.3.1' + // MockWebServer (HTTP contract tests, pinned to the SDK's OkHttp version) + 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/internal/remoteconfig/DeviceRemoteConfigClientContextProvider.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/DeviceRemoteConfigClientContextProvider.kt new file mode 100644 index 000000000..8e447c05e --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/DeviceRemoteConfigClientContextProvider.kt @@ -0,0 +1,65 @@ +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 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 + } + + private fun locale(): String { + val locale = Locale.getDefault() + val language = locale.language.takeIf { it.isNotEmpty() } ?: return UNKNOWN + val country = locale.country + return if (country.isEmpty()) language else "${language}_$country" + } +} 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..e23f143f7 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewaySession.kt @@ -0,0 +1,154 @@ +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_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 +} + +internal interface RemoteConfigSessionStore { + fun load(scope: RemoteConfigSnapshotScope): RemoteConfigGatewaySession? + fun save(scope: RemoteConfigSnapshotScope, session: RemoteConfigGatewaySession): Boolean + fun clear(scope: RemoteConfigSnapshotScope): 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(scope: RemoteConfigSnapshotScope): RemoteConfigGatewaySession? { + val key = remoteConfigSessionStorageKey(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_SESSION_MAX_BYTES } + ?.let(adapter::fromJson) + } catch (_: Exception) { + null + } + if (persisted == null || !persisted.isValid()) { + removeInvalid(key) + return null + } + return RemoteConfigGatewaySession( + token = persisted.token, + projectId = persisted.projectId, + environment = persisted.environment, + expiresAtMillis = persisted.expiresAtMillis, + ) + } + + @Synchronized + @Suppress("ReturnCount") + override fun save(scope: RemoteConfigSnapshotScope, 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(scope) to raw), + removedKeys = emptySet(), + ) + } catch (_: Exception) { + false + } + } + + @Synchronized + override fun clear(scope: RemoteConfigSnapshotScope): Boolean = try { + cache.updateStringsDurably(emptyMap(), setOf(remoteConfigSessionStorageKey(scope))) + } 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, +) + +private fun remoteConfigSessionStorageKey(scope: RemoteConfigSnapshotScope): String { + val digest = MessageDigest.getInstance("SHA-256") + digest.updateLengthPrefixed("remote-config-gateway-session-v1".encodeToByteArray()) + digest.updateLengthPrefixed(scope.projectKey.encodeToByteArray()) + digest.updateLengthPrefixed(scope.environment.encodeToByteArray()) + digest.updateLengthPrefixed(scope.canonicalUserId.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..4d2a919ab --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransport.kt @@ -0,0 +1,512 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +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 java.io.IOException +import java.text.ParsePosition +import java.text.SimpleDateFormat +import java.util.Locale +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_SNAPSHOT_PATH = "v3/remote-config-v2/snapshot" +internal const val REMOTE_CONFIG_SESSION_HEADER = "X-Qonversion-RC-Session" + +private const val REMOTE_CONFIG_USER_UID_MAX_BYTES = 255 +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_NOT_MODIFIED = 304 +private const val HTTP_UNAUTHORIZED = 401 + +/** + * 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. + */ +internal data class RemoteConfigTransportIdentity( + val scope: RemoteConfigSnapshotScope, + val projectToken: String, + val userUid: String, +) { + internal fun isValid(): Boolean = projectToken.isNotEmpty() && + projectToken.trim() == projectToken && + 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. + * + * 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") +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 clock: RemoteConfigFetchClock, + moshi: Moshi, + private val logger: Logger, +) : RemoteConfigFetchTransport { + private val bootstrapRequestAdapter = moshi.adapter(RemoteConfigSessionRequest::class.java) + private val bootstrapResponseAdapter = moshi.adapter(RemoteConfigSessionResponse::class.java) + private val snapshotRequestAdapter = moshi.adapter(RemoteConfigSnapshotRequest::class.java) + + private val lock = Any() + private var cachedScope: RemoteConfigSnapshotScope? = 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.scope) + 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, deliver) { minted -> + requestSnapshot(identity, context, minted, request, deliver, allowReBootstrap = false) + } + } else { + requestSnapshot(identity, context, session, request, deliver, allowReBootstrap = true) + } + } + + @Suppress("LongParameterList") + private fun requestSnapshot( + identity: RemoteConfigTransportIdentity, + context: RemoteConfigClientContext, + session: RemoteConfigGatewaySession, + request: RemoteConfigFetchRequest, + deliver: SingleDelivery, + allowReBootstrap: Boolean, + ) { + val url = resolve(REMOTE_CONFIG_SNAPSHOT_PATH) + val body = try { + snapshotRequestAdapter.toJson(RemoteConfigSnapshotRequest(context.toWire())) + } catch (_: Exception) { + null + } + if (url == null || body == null) { + deliver(RemoteConfigFetchResponse.Failure()) + return + } + val httpRequest = baseRequest(url, identity, body) + .header(REMOTE_CONFIG_SESSION_HEADER, session.token) + .apply { + request.ifNoneMatch + ?.takeIf { it.isNotEmpty() && it.trim() == it } + ?.let { header("If-None-Match", it) } + } + .build() + enqueue(httpRequest, deliver) { outcome -> + onSnapshotOutcome(identity, context, request, deliver, allowReBootstrap, outcome) + } + } + + @Suppress("LongParameterList") + private fun onSnapshotOutcome( + identity: RemoteConfigTransportIdentity, + context: RemoteConfigClientContext, + request: RemoteConfigFetchRequest, + deliver: SingleDelivery, + allowReBootstrap: Boolean, + outcome: HttpOutcome?, + ) { + when { + outcome == null -> deliver(RemoteConfigFetchResponse.Failure()) + outcome.code == HTTP_OK -> deliver(outcome.asSuccessOrFailure()) + outcome.code == HTTP_NOT_MODIFIED -> + deliver(RemoteConfigFetchResponse.NotModified(outcome.etag)) + outcome.code == HTTP_UNAUTHORIZED -> { + forgetSession(identity.scope) + if (!allowReBootstrap) { + logger.debug("Remote Config v2 snapshot stayed unauthorized after re-bootstrap") + deliver(RemoteConfigFetchResponse.Failure(statusCode = outcome.code)) + return + } + reBootstrapOnce(identity, context, request, deliver) + } + else -> deliver(outcome.asFailure()) + } + } + + private fun reBootstrapOnce( + identity: RemoteConfigTransportIdentity, + context: RemoteConfigClientContext, + request: RemoteConfigFetchRequest, + deliver: SingleDelivery, + ) { + mint(identity, deliver) { session -> + requestSnapshot(identity, context, session, request, deliver, allowReBootstrap = false) + } + } + + private fun mint( + identity: RemoteConfigTransportIdentity, + deliver: SingleDelivery, + onMinted: (RemoteConfigGatewaySession) -> Unit, + ) { + val url = resolve(REMOTE_CONFIG_SESSION_PATH) + val body = try { + bootstrapRequestAdapter.toJson(RemoteConfigSessionRequest(identity.userUid)) + } catch (_: Exception) { + null + } + if (url == null || body == null) { + deliver(RemoteConfigFetchResponse.Failure()) + return + } + enqueue(baseRequest(url, identity, body).build(), deliver) { 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. + deliver(if (outcome?.code == HTTP_OK) RemoteConfigFetchResponse.Failure() else outcome.asFailure()) + return@enqueue + } + rememberSession(identity.scope, session) + onMinted(session) + } + } + + private fun baseRequest( + url: HttpUrl, + identity: RemoteConfigTransportIdentity, + body: String, + ): Request.Builder = Request.Builder() + .url(url) + .header("Authorization", "Bearer ${identity.projectToken}") + .header("Content-Type", JSON_CONTENT_TYPE) + // The gateway answers `private, no-store`; declaring it on the request as well keeps a + // shared OkHttp cache from ever synthesising a body the strict parser never saw. + .header("Cache-Control", "no-store") + .post(RequestBody.create(JSON_MEDIA_TYPE, body.toByteArray(Charsets.UTF_8))) + + /** + * Every exit of this method must end in exactly one [deliver] call: 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. + */ + private fun enqueue(request: Request, deliver: SingleDelivery, onOutcome: (HttpOutcome?) -> Unit) { + fun handle(outcome: HttpOutcome?) = try { + onOutcome(outcome) + } catch (_: Throwable) { + deliver(RemoteConfigFetchResponse.Failure()) + } + + 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(), + // `bytes()` is the raw octet stream: no charset decode, no re-encode, no JSON round trip. + body = if (code() == HTTP_NOT_MODIFIED) null else body()?.bytes(), + etag = header("ETag"), + retryAfterMillis = header("Retry-After").parseRetryAfterMillis(), + ) + + @Suppress("ReturnCount") + private fun loadUsableSession(scope: RemoteConfigSnapshotScope): RemoteConfigGatewaySession? { + val now = nowMillis() + synchronized(lock) { + if (cachedScope == scope) { + cachedSession?.let { return it.takeIf { session -> session.isUsable(now) } } + } + } + val persisted = try { + sessionStore.load(scope) + } catch (_: Exception) { + null + } ?: return null + if (!persisted.isUsable(now)) { + forgetSession(scope) + return null + } + synchronized(lock) { + cachedScope = scope + cachedSession = persisted + } + return persisted + } + + private fun rememberSession(scope: RemoteConfigSnapshotScope, session: RemoteConfigGatewaySession) { + synchronized(lock) { + cachedScope = scope + 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(scope, session) + } catch (_: Exception) { + // The in-memory session still serves this process; the next cold start re-bootstraps. + } + } + + private fun forgetSession(scope: RemoteConfigSnapshotScope) { + synchronized(lock) { + if (cachedScope == scope) { + cachedSession = null + cachedScope = null + } + } + try { + sessionStore.clear(scope) + } catch (_: Exception) { + // 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 (_: Exception) { + null + } ?: return null + val token = parsed.sessionToken ?: return null + if (token.isEmpty() || token.trim() != token || + token.toByteArray(Charsets.UTF_8).size > REMOTE_CONFIG_SESSION_TOKEN_HEADER_MAX_BYTES + ) { + return null + } + val projectId = parsed.projectId ?: return null + val environment = parsed.environment?.takeIf { it.isNotEmpty() } ?: return null + if (projectId <= 0) 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 resolve(path: String): HttpUrl? = try { + HttpUrl.parse(baseUrlProvider())?.newBuilder()?.addPathSegments(path)?.build() + } catch (_: Exception) { + null + } + + private fun nowMillis(): Long = try { + clock.nowMillis().coerceAtLeast(0) + } catch (_: Exception) { + 0 + } + + 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: (RemoteConfigFetchResponse) -> Unit, + ) : (RemoteConfigFetchResponse) -> Unit { + private val delivered = AtomicBoolean(false) + + override fun invoke(response: RemoteConfigFetchResponse) { + if (delivered.compareAndSet(false, true)) completion(response) + } + } + + private class HttpOutcome( + val code: Int, + val body: ByteArray?, + val etag: String?, + val retryAfterMillis: Long?, + ) { + fun asSuccessOrFailure(): RemoteConfigFetchResponse { + val bytes = body + val validator = etag + return if (bytes == null || validator.isNullOrEmpty()) { + // A 200 without a strong validator cannot be admitted, and it is not retryable. + RemoteConfigFetchResponse.Failure() + } else { + RemoteConfigFetchResponse.Success(bytes, validator) + } + } + } + + 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, + ) + } +} + +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 + } + +private fun String?.parseRfc3339Millis(): Long? { + val value = this?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val normalized = value.replace("Z", "+0000").replace(Regex("([+\\-]\\d{2}):(\\d{2})$"), "$1$2") + return RFC3339_FORMATS.firstNotNullOfOrNull { pattern -> + val format = SimpleDateFormat(pattern, Locale.US).apply { + isLenient = false + timeZone = TimeZone.getTimeZone("UTC") + } + val position = ParsePosition(0) + val parsed = format.parse(normalized, position) + parsed?.takeIf { position.index == normalized.length }?.time + } +} + +private val RFC3339_FORMATS = listOf( + "yyyy-MM-dd'T'HH:mm:ssZ", + "yyyy-MM-dd'T'HH:mm:ss.SSSZ", +) + +@JsonClass(generateAdapter = true) +internal data class RemoteConfigSessionRequest( + @Json(name = "user_uid") val userUid: 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 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/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/PersistentRemoteConfigSessionStoreTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigSessionStoreTest.kt new file mode 100644 index 000000000..1d4990579 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigSessionStoreTest.kt @@ -0,0 +1,108 @@ +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 otherIdentity = RemoteConfigSnapshotScope("project-secret", "env-production", "other-secret") + 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(scope, session)) + + val persistedKey = cache.strings.keys.single() + assertFalse(persistedKey.contains("project-secret")) + assertFalse(persistedKey.contains("customer-secret")) + assertEquals(session, store(cache).load(scope)) + } + + @Test + fun `an identity change addresses a different record and never reads the previous token`() { + val cache = MapCache() + assertTrue(store(cache).save(scope, 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(scope)?.token) + assertEquals("qrcs1.other", store(cache).load(otherIdentity)?.token) + } + + @Test + fun `clearing drops only the addressed identity`() { + val cache = MapCache() + val sessionStore = store(cache) + assertTrue(sessionStore.save(scope, session)) + assertTrue(sessionStore.save(otherIdentity, session.copy(token = "qrcs1.other"))) + + assertTrue(sessionStore.clear(scope)) + + assertNull(sessionStore.load(scope)) + 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(scope, 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(scope)) + 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(scope, session.copy(token = ""))) + assertFalse(store(cache).save(scope, session.copy(projectId = 0))) + assertFalse(store(cache).save(scope, 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/RemoteConfigGatewayTransportCoordinatorTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportCoordinatorTest.kt new file mode 100644 index 000000000..4b48dd5d3 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportCoordinatorTest.kt @@ -0,0 +1,262 @@ +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.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(BINDING) + 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(BINDING) + 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 `a stalled gateway times out through the fetch policy while the socket is left alone`() { + val core = core() + val coordinator = coordinator(core) + coordinator.transitionTo(BINDING) + 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) + } + + private fun fetch(coordinator: RemoteConfigFetchCoordinator): RemoteConfigFetchResult { + val latch = CountDownLatch(1) + var result: RemoteConfigFetchResult? = null + coordinator.fetch { 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(), + clock = { CLOCK_MILLIS }, + moshi = Moshi.Builder().build(), + logger = SilentLogger(), + ) + + private fun sessionResponse() = MockResponse() + .setResponseCode(200) + .setBody( + "{\"session_token\":\"qrcs1.session-secret\",\"project_id\":42," + + "\"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(scope: RemoteConfigSnapshotScope) = sessions[scope] + + @Synchronized + override fun save(scope: RemoteConfigSnapshotScope, session: RemoteConfigGatewaySession): Boolean { + sessions[scope] = session + return true + } + + @Synchronized + override fun clear(scope: RemoteConfigSnapshotScope): Boolean { + sessions.remove(scope) + 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 BINDING = RemoteConfigFetchBinding( + scope = SCOPE, + expectation = RemoteConfigSnapshotEnvelopeExpectation( + projectId = 42, + environmentUid = "production", + contextFingerprint = "a".repeat(64), + ), + ) + 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..0e94949eb --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportTest.kt @@ -0,0 +1,443 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +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.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.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +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 `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 the exact response bytes and etag to the admission seam`() { + // 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(SCOPE_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(SCOPE_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(SCOPE_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() + val keysAfterFirstIdentity = cache.strings.keys.toSet() + assertEquals(1, keysAfterFirstIdentity.size) + assertFalse(keysAfterFirstIdentity.single().contains(USER_A)) + assertFalse(keysAfterFirstIdentity.single().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)) + assertEquals(2, cache.strings.size) + assertEquals(SESSION_TOKEN, store().load(SCOPE_A)?.token) + assertEquals(OTHER_SESSION_TOKEN, store().load(SCOPE_B)?.token) + } + + @Test + fun `a persisted session is reused without another bootstrap until it expires`() { + persistSession(SCOPE_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(SCOPE_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(SCOPE_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(SCOPE_A)) + } + + @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(SCOPE_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() = RemoteConfigGatewayTransport( + callFactory = client, + baseUrlProvider = { server.url("/").toString() }, + identityProvider = { identity }, + clientContextProvider = { clientContext }, + sessionStore = store(), + clock = clock, + moshi = Moshi.Builder().build(), + logger = logger, + ) + + private fun store() = PersistentRemoteConfigSessionStore(cache, Moshi.Builder().build()) + + private fun persistSession( + scope: RemoteConfigSnapshotScope, + token: String, + expiresAtMillis: Long = clock.now + 3_600_000, + ) { + assertTrue( + store().save( + scope, + RemoteConfigGatewaySession( + token = token, + projectId = 42, + environment = "prod", + expiresAtMillis = expiresAtMillis, + ), + ), + ) + } + + private fun sessionResponse(token: String) = MockResponse() + .setResponseCode(200) + .setHeader("Cache-Control", "private, no-store") + .setBody( + "{\"session_token\":\"$token\",\"project_id\":42,\"environment\":\"prod\"," + + "\"expires_at\":\"2030-01-01T00:00:00Z\"}", + ) + + 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) = + RemoteConfigTransportIdentity(scope, PROJECT_TOKEN, userUid) + + 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 PROJECT_TOKEN = "project-key-secret" + 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 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, + ) + } +} From e7c7d7783f70327fa08005ab450e715774d026c3 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Fri, 7 Aug 2026 11:39:57 +0300 Subject: [PATCH 13/30] review: harden the v2 gateway transport against review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the adapter found four real defects and several weaker-than-claimed tests. All are addressed here. - A session or project token containing a non-printable byte reached Request.Builder.header, which throws — on an OkHttp dispatcher thread, from the mint callback and the 401 retry. That both stranded the coordinator's waiter (no completion) and put the credential into the exception message, so a token could reach a crash reporter. Tokens are now validated as HTTP header values, and request building is failure-typed instead of throwing. - The RFC3339 expiry parser only accepted exactly three fractional digits and an uppercase T/Z, so a Go gateway's RFC3339Nano timestamp parsed as "unknown" and every fetch silently re-bootstrapped forever. Replaced with a parser that accepts 0-9 fractional digits, either case, and numeric offsets. - A dead in-memory session shadowed the durable record and skipped its cleanup. - The session store is now keyed by the anonymous uid the session was minted for, not only by the snapshot scope, so a re-minted uid under an unchanged scope can never replay the previous identity's token. Also: bounded (and injectable) snapshot body read, an empty 200 body is a typed failure rather than an empty admission, Accept header, BCP-47 locale so Hebrew and Indonesian are not sent as the legacy iw/in codes, and MockWebServer pinned to the OkHttp version that actually resolves rather than the declared one. New coverage: non-printable tokens, fractional/lowercase expiry, over-budget and empty bodies, in-memory session reuse, concurrent fetches each answered exactly once, and a re-minted uid addressing a different session record. Claude-Session: https://claude.ai/code/session_018pHXqfbxkMQJFzUZ3jW4A8 --- sdk/build.gradle | 7 +- ...DeviceRemoteConfigClientContextProvider.kt | 17 +- .../RemoteConfigGatewaySession.kt | 44 ++-- .../RemoteConfigGatewayTransport.kt | 240 +++++++++++------- .../PersistentRemoteConfigSessionStoreTest.kt | 43 +++- ...teConfigGatewayTransportCoordinatorTest.kt | 18 +- .../RemoteConfigGatewayTransportTest.kt | 157 ++++++++++-- 7 files changed, 372 insertions(+), 154 deletions(-) diff --git a/sdk/build.gradle b/sdk/build.gradle index 53dcee0bd..c095bddbb 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -85,7 +85,10 @@ ext { core : "com.squareup.retrofit2:retrofit:$retrofit_version", moshiConverter : "com.squareup.retrofit2:converter-moshi:$retrofit_version", okhttp : "com.squareup.okhttp3:okhttp:$okhttp_version", - mockWebServer : "com.squareup.okhttp3:mockwebserver:$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 = [ @@ -165,7 +168,7 @@ dependencies { // Mockito testImplementation 'org.mockito:mockito-core:4.3.1' - // MockWebServer (HTTP contract tests, pinned to the SDK's OkHttp version) + // MockWebServer (HTTP contract tests) testImplementation network.mockWebServer testImplementation 'androidx.test:core:1.5.0' 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 index 8e447c05e..9794f8f52 100644 --- 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 @@ -7,6 +7,7 @@ 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 /** @@ -56,10 +57,18 @@ internal class DeviceRemoteConfigClientContextProvider( 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 locale = Locale.getDefault() - val language = locale.language.takeIf { it.isNotEmpty() } ?: return UNKNOWN - val country = locale.country - return if (country.isEmpty()) language else "${language}_$country" + 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/RemoteConfigGatewaySession.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewaySession.kt index e23f143f7..6ec981174 100644 --- 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 @@ -30,10 +30,23 @@ internal data class RemoteConfigGatewaySession( 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(scope: RemoteConfigSnapshotScope): RemoteConfigGatewaySession? - fun save(scope: RemoteConfigSnapshotScope, session: RemoteConfigGatewaySession): Boolean - fun clear(scope: RemoteConfigSnapshotScope): Boolean + fun load(key: RemoteConfigSessionKey): RemoteConfigGatewaySession? + fun save(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession): Boolean + fun clear(key: RemoteConfigSessionKey): Boolean } /** @@ -51,10 +64,10 @@ internal class PersistentRemoteConfigSessionStore( @Synchronized @Suppress("ReturnCount") - override fun load(scope: RemoteConfigSnapshotScope): RemoteConfigGatewaySession? { - val key = remoteConfigSessionStorageKey(scope) + override fun load(key: RemoteConfigSessionKey): RemoteConfigGatewaySession? { + val storageKey = remoteConfigSessionStorageKey(key) val raw = try { - cache.getString(key, null) + cache.getString(storageKey, null) } catch (_: Exception) { null } ?: return null @@ -65,7 +78,7 @@ internal class PersistentRemoteConfigSessionStore( null } if (persisted == null || !persisted.isValid()) { - removeInvalid(key) + removeInvalid(storageKey) return null } return RemoteConfigGatewaySession( @@ -78,7 +91,7 @@ internal class PersistentRemoteConfigSessionStore( @Synchronized @Suppress("ReturnCount") - override fun save(scope: RemoteConfigSnapshotScope, session: RemoteConfigGatewaySession): Boolean { + override fun save(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession): Boolean { val persisted = PersistedRemoteConfigGatewaySession( version = REMOTE_CONFIG_SESSION_VERSION, token = session.token, @@ -95,7 +108,7 @@ internal class PersistentRemoteConfigSessionStore( if (raw.toByteArray(Charsets.UTF_8).size > REMOTE_CONFIG_SESSION_MAX_BYTES) return false return try { cache.updateStringsDurably( - values = mapOf(remoteConfigSessionStorageKey(scope) to raw), + values = mapOf(remoteConfigSessionStorageKey(key) to raw), removedKeys = emptySet(), ) } catch (_: Exception) { @@ -104,8 +117,8 @@ internal class PersistentRemoteConfigSessionStore( } @Synchronized - override fun clear(scope: RemoteConfigSnapshotScope): Boolean = try { - cache.updateStringsDurably(emptyMap(), setOf(remoteConfigSessionStorageKey(scope))) + override fun clear(key: RemoteConfigSessionKey): Boolean = try { + cache.updateStringsDurably(emptyMap(), setOf(remoteConfigSessionStorageKey(key))) } catch (_: Exception) { false } @@ -139,12 +152,13 @@ internal data class PersistedRemoteConfigGatewaySession( val expiresAtMillis: Long, ) -private fun remoteConfigSessionStorageKey(scope: RemoteConfigSnapshotScope): String { +private fun remoteConfigSessionStorageKey(key: RemoteConfigSessionKey): String { val digest = MessageDigest.getInstance("SHA-256") digest.updateLengthPrefixed("remote-config-gateway-session-v1".encodeToByteArray()) - digest.updateLengthPrefixed(scope.projectKey.encodeToByteArray()) - digest.updateLengthPrefixed(scope.environment.encodeToByteArray()) - digest.updateLengthPrefixed(scope.canonicalUserId.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) } } 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 index 4d2a919ab..3f234e4fa 100644 --- 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 @@ -11,16 +11,16 @@ import okhttp3.MediaType import okhttp3.Request import okhttp3.RequestBody import okhttp3.Response +import okhttp3.ResponseBody import java.io.IOException -import java.text.ParsePosition -import java.text.SimpleDateFormat -import java.util.Locale +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_SNAPSHOT_PATH = "v3/remote-config-v2/snapshot" 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_SESSION_TOKEN_HEADER_MAX_BYTES = 512 @@ -30,6 +30,8 @@ private const val MILLIS_PER_SECOND = 1_000L private const val HTTP_OK = 200 private const val HTTP_NOT_MODIFIED = 304 private const val HTTP_UNAUTHORIZED = 401 +private const val ASCII_PRINTABLE_MIN = 0x20 +private const val ASCII_PRINTABLE_MAX = 0x7e /** * Device-scoped facts the gateway needs to evaluate targeting. @@ -73,14 +75,20 @@ internal fun interface RemoteConfigClientContextProvider { * 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, ) { + internal val sessionKey: RemoteConfigSessionKey get() = RemoteConfigSessionKey(scope, userUid) + internal fun isValid(): Boolean = projectToken.isNotEmpty() && - projectToken.trim() == projectToken && + projectToken.isHttpHeaderSafe() && userUid.isNotEmpty() && userUid.toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_USER_UID_MAX_BYTES && !userUid.contains(UNICODE_REPLACEMENT_CHARACTER) @@ -106,6 +114,10 @@ internal fun interface RemoteConfigTransportIdentityProvider { * 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. * + * 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 [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. @@ -122,13 +134,14 @@ internal class RemoteConfigGatewayTransport( private val clock: RemoteConfigFetchClock, moshi: Moshi, private val logger: Logger, + private val maxSnapshotBodyBytes: Long = REMOTE_CONFIG_SNAPSHOT_BODY_MAX_BYTES, ) : RemoteConfigFetchTransport { private val bootstrapRequestAdapter = moshi.adapter(RemoteConfigSessionRequest::class.java) private val bootstrapResponseAdapter = moshi.adapter(RemoteConfigSessionResponse::class.java) private val snapshotRequestAdapter = moshi.adapter(RemoteConfigSnapshotRequest::class.java) private val lock = Any() - private var cachedScope: RemoteConfigSnapshotScope? = null + private var cachedKey: RemoteConfigSessionKey? = null private var cachedSession: RemoteConfigGatewaySession? = null override fun fetch( @@ -143,7 +156,7 @@ internal class RemoteConfigGatewayTransport( deliver(RemoteConfigFetchResponse.Failure()) return } - val session = loadUsableSession(identity.scope) + 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. @@ -164,24 +177,23 @@ internal class RemoteConfigGatewayTransport( deliver: SingleDelivery, allowReBootstrap: Boolean, ) { - val url = resolve(REMOTE_CONFIG_SNAPSHOT_PATH) val body = try { snapshotRequestAdapter.toJson(RemoteConfigSnapshotRequest(context.toWire())) - } catch (_: Exception) { + } catch (_: Throwable) { null } - if (url == null || body == 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 } - val httpRequest = baseRequest(url, identity, body) - .header(REMOTE_CONFIG_SESSION_HEADER, session.token) - .apply { - request.ifNoneMatch - ?.takeIf { it.isNotEmpty() && it.trim() == it } - ?.let { header("If-None-Match", it) } - } - .build() enqueue(httpRequest, deliver) { outcome -> onSnapshotOutcome(identity, context, request, deliver, allowReBootstrap, outcome) } @@ -202,45 +214,36 @@ internal class RemoteConfigGatewayTransport( outcome.code == HTTP_NOT_MODIFIED -> deliver(RemoteConfigFetchResponse.NotModified(outcome.etag)) outcome.code == HTTP_UNAUTHORIZED -> { - forgetSession(identity.scope) + forgetSession(identity.sessionKey) if (!allowReBootstrap) { logger.debug("Remote Config v2 snapshot stayed unauthorized after re-bootstrap") deliver(RemoteConfigFetchResponse.Failure(statusCode = outcome.code)) return } - reBootstrapOnce(identity, context, request, deliver) + mint(identity, deliver) { session -> + requestSnapshot(identity, context, session, request, deliver, allowReBootstrap = false) + } } else -> deliver(outcome.asFailure()) } } - private fun reBootstrapOnce( - identity: RemoteConfigTransportIdentity, - context: RemoteConfigClientContext, - request: RemoteConfigFetchRequest, - deliver: SingleDelivery, - ) { - mint(identity, deliver) { session -> - requestSnapshot(identity, context, session, request, deliver, allowReBootstrap = false) - } - } - private fun mint( identity: RemoteConfigTransportIdentity, deliver: SingleDelivery, onMinted: (RemoteConfigGatewaySession) -> Unit, ) { - val url = resolve(REMOTE_CONFIG_SESSION_PATH) val body = try { bootstrapRequestAdapter.toJson(RemoteConfigSessionRequest(identity.userUid)) - } catch (_: Exception) { + } catch (_: Throwable) { null } - if (url == null || body == null) { + val httpRequest = body?.let { buildRequest(REMOTE_CONFIG_SESSION_PATH, identity, it) } + if (httpRequest == null) { deliver(RemoteConfigFetchResponse.Failure()) return } - enqueue(baseRequest(url, identity, body).build(), deliver) { outcome -> + enqueue(httpRequest, deliver) { outcome -> val session = outcome ?.takeIf { it.code == HTTP_OK } ?.body @@ -252,23 +255,38 @@ internal class RemoteConfigGatewayTransport( deliver(if (outcome?.code == HTTP_OK) RemoteConfigFetchResponse.Failure() else outcome.asFailure()) return@enqueue } - rememberSession(identity.scope, session) + rememberSession(identity.sessionKey, session) onMinted(session) } } - private fun baseRequest( - url: HttpUrl, + /** + * 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, - ): Request.Builder = Request.Builder() - .url(url) - .header("Authorization", "Bearer ${identity.projectToken}") - .header("Content-Type", JSON_CONTENT_TYPE) - // The gateway answers `private, no-store`; declaring it on the request as well keeps a - // shared OkHttp cache from ever synthesising a body the strict parser never saw. - .header("Cache-Control", "no-store") - .post(RequestBody.create(JSON_MEDIA_TYPE, body.toByteArray(Charsets.UTF_8))) + 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 [deliver] call: the coordinator parks a @@ -309,61 +327,69 @@ internal class RemoteConfigGatewayTransport( private fun Response.toOutcome(): HttpOutcome = HttpOutcome( code = code(), - // `bytes()` is the raw octet stream: no charset decode, no re-encode, no JSON round trip. - body = if (code() == HTTP_NOT_MODIFIED) null else body()?.bytes(), + 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(scope: RemoteConfigSnapshotScope): RemoteConfigGatewaySession? { + private fun loadUsableSession(key: RemoteConfigSessionKey): RemoteConfigGatewaySession? { val now = nowMillis() - synchronized(lock) { - if (cachedScope == scope) { - cachedSession?.let { return it.takeIf { session -> session.isUsable(now) } } - } - } + 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(scope) - } catch (_: Exception) { + sessionStore.load(key) + } catch (_: Throwable) { null - } ?: return null - if (!persisted.isUsable(now)) { - forgetSession(scope) + } + if (persisted == null || !persisted.isUsable(now)) { + forgetSession(key) return null } synchronized(lock) { - cachedScope = scope + cachedKey = key cachedSession = persisted } return persisted } - private fun rememberSession(scope: RemoteConfigSnapshotScope, session: RemoteConfigGatewaySession) { + private fun rememberSession(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession) { synchronized(lock) { - cachedScope = scope + 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(scope, session) - } catch (_: Exception) { + sessionStore.save(key, session) + } catch (_: Throwable) { // The in-memory session still serves this process; the next cold start re-bootstraps. } } - private fun forgetSession(scope: RemoteConfigSnapshotScope) { + private fun forgetSession(key: RemoteConfigSessionKey) { synchronized(lock) { - if (cachedScope == scope) { + if (cachedKey == key) { cachedSession = null - cachedScope = null + cachedKey = null } } try { - sessionStore.clear(scope) - } catch (_: Exception) { + sessionStore.clear(key) + } catch (_: Throwable) { // A stale record is re-validated (and dropped again) on the next load. } } @@ -372,18 +398,13 @@ internal class RemoteConfigGatewayTransport( private fun readSession(body: ByteArray): RemoteConfigGatewaySession? { val parsed = try { bootstrapResponseAdapter.fromJson(body.toString(Charsets.UTF_8)) - } catch (_: Exception) { + } catch (_: Throwable) { null } ?: return null val token = parsed.sessionToken ?: return null - if (token.isEmpty() || token.trim() != token || - token.toByteArray(Charsets.UTF_8).size > REMOTE_CONFIG_SESSION_TOKEN_HEADER_MAX_BYTES - ) { - return null - } - val projectId = parsed.projectId ?: 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 - if (projectId <= 0) return null return RemoteConfigGatewaySession( token = token, projectId = projectId, @@ -397,15 +418,9 @@ internal class RemoteConfigGatewayTransport( private fun RemoteConfigGatewaySession.isUsable(nowMillis: Long): Boolean = isUsableAt(nowMillis + REMOTE_CONFIG_SESSION_EXPIRY_SKEW_MILLIS) - private fun resolve(path: String): HttpUrl? = try { - HttpUrl.parse(baseUrlProvider())?.newBuilder()?.addPathSegments(path)?.build() - } catch (_: Exception) { - null - } - private fun nowMillis(): Long = try { clock.nowMillis().coerceAtLeast(0) - } catch (_: Exception) { + } catch (_: Throwable) { 0 } @@ -438,8 +453,9 @@ internal class RemoteConfigGatewayTransport( fun asSuccessOrFailure(): RemoteConfigFetchResponse { val bytes = body val validator = etag - return if (bytes == null || validator.isNullOrEmpty()) { - // A 200 without a strong validator cannot be admitted, and it is not retryable. + return if (bytes == null || bytes.isEmpty() || validator.isNullOrEmpty()) { + // An empty body, an over-budget body or a 200 without a strong validator cannot be + // admitted, and none of them is retryable. RemoteConfigFetchResponse.Failure() } else { RemoteConfigFetchResponse.Success(bytes, validator) @@ -458,28 +474,56 @@ internal class RemoteConfigGatewayTransport( } } +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 value = this?.trim()?.takeIf { it.isNotEmpty() } ?: return null - val normalized = value.replace("Z", "+0000").replace(Regex("([+\\-]\\d{2}):(\\d{2})$"), "$1$2") - return RFC3339_FORMATS.firstNotNullOfOrNull { pattern -> - val format = SimpleDateFormat(pattern, Locale.US).apply { - isLenient = false - timeZone = TimeZone.getTimeZone("UTC") - } - val position = ParsePosition(0) - val parsed = format.parse(normalized, position) - parsed?.takeIf { position.index == normalized.length }?.time + 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_FORMATS = listOf( - "yyyy-MM-dd'T'HH:mm:ssZ", - "yyyy-MM-dd'T'HH:mm:ss.SSSZ", +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) 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 index 1d4990579..d462ff04d 100644 --- 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 @@ -11,7 +11,12 @@ import org.junit.Test internal class PersistentRemoteConfigSessionStoreTest { private val scope = RemoteConfigSnapshotScope("project-secret", "env-production", "customer-secret") - private val otherIdentity = RemoteConfigSnapshotScope("project-secret", "env-production", "other-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, @@ -22,36 +27,48 @@ internal class PersistentRemoteConfigSessionStoreTest { @Test fun `session is durably scoped and survives a new store instance`() { val cache = MapCache() - assertTrue(store(cache).save(scope, session)) + assertTrue(store(cache).save(key, session)) val persistedKey = cache.strings.keys.single() assertFalse(persistedKey.contains("project-secret")) assertFalse(persistedKey.contains("customer-secret")) - assertEquals(session, store(cache).load(scope)) + 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(scope, session)) + 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(scope)?.token) + 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(scope, session)) + assertTrue(sessionStore.save(key, session)) assertTrue(sessionStore.save(otherIdentity, session.copy(token = "qrcs1.other"))) - assertTrue(sessionStore.clear(scope)) + assertTrue(sessionStore.clear(key)) - assertNull(sessionStore.load(scope)) + assertNull(sessionStore.load(key)) assertEquals("qrcs1.other", sessionStore.load(otherIdentity)?.token) } @@ -59,13 +76,13 @@ internal class PersistentRemoteConfigSessionStoreTest { fun `malformed persisted session is removed fail closed`() { val cache = MapCache() val sessionStore = store(cache) - assertTrue(sessionStore.save(scope, session)) + 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(scope)) + assertNull(sessionStore.load(key)) assertFalse(cache.strings.containsKey(persistedKey)) } @@ -73,9 +90,9 @@ internal class PersistentRemoteConfigSessionStoreTest { fun `a session that cannot be described is refused rather than half written`() { val cache = MapCache() - assertFalse(store(cache).save(scope, session.copy(token = ""))) - assertFalse(store(cache).save(scope, session.copy(projectId = 0))) - assertFalse(store(cache).save(scope, session.copy(expiresAtMillis = 0))) + 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()) } 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 index 4b48dd5d3..7156bbca5 100644 --- 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 @@ -13,6 +13,7 @@ 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.assertTrue import org.junit.Before import org.junit.Test @@ -89,7 +90,7 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { } @Test - fun `a stalled gateway times out through the fetch policy while the socket is left alone`() { + fun `a stalled gateway times out through the fetch policy`() { val core = core() val coordinator = coordinator(core) coordinator.transitionTo(BINDING) @@ -105,6 +106,9 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { 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): RemoteConfigFetchResult { @@ -183,20 +187,20 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { } private class InMemorySessionStore : RemoteConfigSessionStore { - private val sessions = mutableMapOf() + private val sessions = mutableMapOf() @Synchronized - override fun load(scope: RemoteConfigSnapshotScope) = sessions[scope] + override fun load(key: RemoteConfigSessionKey) = sessions[key] @Synchronized - override fun save(scope: RemoteConfigSnapshotScope, session: RemoteConfigGatewaySession): Boolean { - sessions[scope] = session + override fun save(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession): Boolean { + sessions[key] = session return true } @Synchronized - override fun clear(scope: RemoteConfigSnapshotScope): Boolean { - sessions.remove(scope) + override fun clear(key: RemoteConfigSessionKey): Boolean { + sessions.remove(key) return true } } 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 index 0e94949eb..d9b944211 100644 --- 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 @@ -5,8 +5,10 @@ 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 @@ -17,6 +19,7 @@ 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 @@ -93,7 +96,7 @@ internal class RemoteConfigGatewayTransportTest { } @Test - fun `200 hands the exact response bytes and etag to the admission seam`() { + 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. @@ -119,7 +122,7 @@ internal class RemoteConfigGatewayTransportTest { @Test fun `snapshot 401 re-bootstraps once and retries successfully`() { - persistSession(SCOPE_A, "stale-token") + 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)) @@ -139,7 +142,7 @@ internal class RemoteConfigGatewayTransportTest { @Test fun `two consecutive 401s fail typed without looping`() { - persistSession(SCOPE_A, "stale-token") + persistSession(KEY_A, "stale-token") server.enqueue(MockResponse().setResponseCode(401)) server.enqueue(sessionResponse(SESSION_TOKEN)) server.enqueue(MockResponse().setResponseCode(401)) @@ -148,7 +151,7 @@ internal class RemoteConfigGatewayTransportTest { assertEquals(RemoteConfigFetchResponse.Failure(statusCode = 401), response) assertEquals(3, server.requestCount) - assertNull(store().load(SCOPE_A)) + assertNull(store().load(KEY_A)) } @Test @@ -215,13 +218,13 @@ internal class RemoteConfigGatewayTransportTest { val snapshot = server.takeRequest() assertEquals(OTHER_SESSION_TOKEN, snapshot.getHeader(REMOTE_CONFIG_SESSION_HEADER)) assertEquals(2, cache.strings.size) - assertEquals(SESSION_TOKEN, store().load(SCOPE_A)?.token) - assertEquals(OTHER_SESSION_TOKEN, store().load(SCOPE_B)?.token) + assertEquals(SESSION_TOKEN, store().load(KEY_A)?.token) + assertEquals(OTHER_SESSION_TOKEN, store().load(KEY_B)?.token) } @Test fun `a persisted session is reused without another bootstrap until it expires`() { - persistSession(SCOPE_A, SESSION_TOKEN, expiresAtMillis = clock.now + 3_600_000) + persistSession(KEY_A, SESSION_TOKEN, expiresAtMillis = clock.now + 3_600_000) server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) assertTrue(fetch(RemoteConfigFetchRequest()) is RemoteConfigFetchResponse.Success) @@ -231,7 +234,7 @@ internal class RemoteConfigGatewayTransportTest { @Test fun `an expired persisted session is dropped and re-bootstrapped`() { - persistSession(SCOPE_A, "expired-token", expiresAtMillis = clock.now - 1) + persistSession(KEY_A, "expired-token", expiresAtMillis = clock.now - 1) server.enqueue(sessionResponse(SESSION_TOKEN)) server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) @@ -265,7 +268,7 @@ internal class RemoteConfigGatewayTransportTest { @Test fun `neither the project token nor the session token is ever logged`() { - persistSession(SCOPE_A, "stale-token") + persistSession(KEY_A, "stale-token") server.enqueue(MockResponse().setResponseCode(401)) server.enqueue(sessionResponse(SESSION_TOKEN)) server.enqueue(MockResponse().setResponseCode(401)) @@ -301,7 +304,101 @@ internal class RemoteConfigGatewayTransportTest { server.enqueue(sessionResponse(" padded-token ")) assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) - assertNull(store().load(SCOPE_A)) + 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 @@ -317,7 +414,7 @@ internal class RemoteConfigGatewayTransportTest { assertTrue(fetch(RemoteConfigFetchRequest()) is RemoteConfigFetchResponse.Success) server.takeRequest() assertEquals(SESSION_TOKEN, server.takeRequest().getHeader(REMOTE_CONFIG_SESSION_HEADER)) - assertNull(store().load(SCOPE_A)) + assertNull(store().load(KEY_A)) } private fun fetch( @@ -334,27 +431,31 @@ internal class RemoteConfigGatewayTransportTest { return requireNotNull(received) } - private fun transport() = RemoteConfigGatewayTransport( + private fun transport( + sessionStore: RemoteConfigSessionStore = store(), + maxSnapshotBodyBytes: Long = REMOTE_CONFIG_SNAPSHOT_BODY_MAX_BYTES, + ) = RemoteConfigGatewayTransport( callFactory = client, baseUrlProvider = { server.url("/").toString() }, identityProvider = { identity }, clientContextProvider = { clientContext }, - sessionStore = store(), + sessionStore = sessionStore, clock = clock, moshi = Moshi.Builder().build(), logger = logger, + maxSnapshotBodyBytes = maxSnapshotBodyBytes, ) private fun store() = PersistentRemoteConfigSessionStore(cache, Moshi.Builder().build()) private fun persistSession( - scope: RemoteConfigSnapshotScope, + key: RemoteConfigSessionKey, token: String, expiresAtMillis: Long = clock.now + 3_600_000, ) { assertTrue( store().save( - scope, + key, RemoteConfigGatewaySession( token = token, projectId = 42, @@ -373,6 +474,13 @@ internal class RemoteConfigGatewayTransportTest { "\"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) @@ -382,6 +490,22 @@ internal class RemoteConfigGatewayTransportTest { private fun identityFor(scope: RemoteConfigSnapshotScope, userUid: String) = RemoteConfigTransportIdentity(scope, PROJECT_TOKEN, userUid) + /** 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 } @@ -421,6 +545,7 @@ internal class RemoteConfigGatewayTransportTest { private companion object { const val AWAIT_SECONDS = 10L + const val THREADS = 4 const val PROJECT_TOKEN = "project-key-secret" const val SESSION_TOKEN = "qrcs1.session-secret" const val OTHER_SESSION_TOKEN = "qrcs1.other-session-secret" @@ -428,6 +553,8 @@ internal class RemoteConfigGatewayTransportTest { 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( From 3de2672c28a3036e639bb9bbafe89e1ae179e3cc Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Fri, 7 Aug 2026 13:23:56 +0300 Subject: [PATCH 14/30] feat(remote-config): expose the v2 snapshot API Adds the customer-facing Remote Config v2 surface on top of the internal snapshot core, fetch coordinator and gateway transport landed in the previous slices. Nothing here re-implements them: the public types are thin, immutable adapters over the resolution ladder, the read guard and the coordinator's waiter model. The surface (all marked @ExperimentalQonversionApi, so it is not yet a stability promise): - Qonversion.remoteConfigSnapshots() -> QRemoteConfigSnapshots, with fetch(timeoutMs?), activate(), fetchAndActivate(), an immutable `current` snapshot, a synchronous bundled-fallback getter and subscribeOnConfigUpdate(). - Reads return {value, source}: raw JSON, an opaque JSON tree, or a caller-decoded type. The decoder is the per-key validator seam, so a rejected value falls to the previously activated release (cache) and then to the bundled defaults. - fetch's timeout bounds the wait, not the request: the completion reports the best available snapshot while the request keeps running and is still admitted when it lands. - Activation stays a whole-release atomic swap; an immediate-policy release performs that same swap on admission and notifies subscribers with the changed-key diff and per-key metadata. - Identity changes switch the scope synchronously (the previous identity's release is never readable afterwards) and force a fetch; an identify that only attaches an external id re-reads targeting without dropping the served release. The pipeline is dormant unless the app passes a QRemoteConfigV2Config: without it no store, thread, HTTP client or base URL is constructed, and every fetch completes with NotConfigured. There is no default endpoint. Tests cover the contract end to end against a real MockWebServer with the real core, guard and coordinator: timeout semantics, change detection, all three ladder positions, raw vs typed reads, the pre-activate fallback getter, subscription diffs, immediate auto-activation, the identity switch (old snapshot excluded, per-identity session, install date pinned against a real PackageManager) and main-thread delivery. Claude-Session: https://claude.ai/code/session_018pHXqfbxkMQJFzUZ3jW4A8 --- .../android/sdk/ExperimentalQonversionApi.kt | 26 + .../android/sdk/QRemoteConfigSnapshots.kt | 93 +++ .../com/qonversion/android/sdk/Qonversion.kt | 17 + .../android/sdk/QonversionConfig.kt | 26 +- .../QRemoteConfigActivationResult.kt | 19 + .../remoteconfig/QRemoteConfigApplyPolicy.kt | 21 + .../dto/remoteconfig/QRemoteConfigDecoder.kt | 22 + .../remoteconfig/QRemoteConfigFetchResult.kt | 20 + .../remoteconfig/QRemoteConfigFetchStatus.kt | 37 ++ .../dto/remoteconfig/QRemoteConfigSnapshot.kt | 96 +++ .../dto/remoteconfig/QRemoteConfigSource.kt | 25 + .../remoteconfig/QRemoteConfigSubscription.kt | 13 + .../dto/remoteconfig/QRemoteConfigUpdate.kt | 35 ++ .../dto/remoteconfig/QRemoteConfigV2Config.kt | 47 ++ .../dto/remoteconfig/QRemoteConfigValue.kt | 22 + .../android/sdk/internal/InternalConfig.kt | 8 +- .../sdk/internal/QRemoteConfigManager.kt | 10 +- .../sdk/internal/QonversionInternal.kt | 29 + .../remoteconfig/MainThreadDispatcher.kt | 26 + .../QRemoteConfigSnapshotsImpl.kt | 83 +++ .../RemoteConfigIdentityBridge.kt | 36 ++ .../remoteconfig/RemoteConfigV2Factory.kt | 190 ++++++ .../remoteconfig/RemoteConfigV2Manager.kt | 323 ++++++++++ .../services/BundledRemoteConfigDefaults.kt | 12 + .../listeners/QRemoteConfigUpdateListener.kt | 15 + ...onversionRemoteConfigActivationCallback.kt | 12 + .../QonversionRemoteConfigFetchCallback.kt | 12 + .../remoteconfig/QRemoteConfigV2ConfigTest.kt | 60 ++ .../QRemoteConfigsPublicApiTest.kt | 554 ++++++++++++++++++ .../RemoteConfigFetchCoordinatorTest.kt | 6 + .../RemoteConfigV2DeviceScopeTest.kt | 70 +++ .../remoteconfig/RemoteConfigV2TestHarness.kt | 439 ++++++++++++++ 32 files changed, 2399 insertions(+), 5 deletions(-) create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/ExperimentalQonversionApi.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/QRemoteConfigSnapshots.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigActivationResult.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigApplyPolicy.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigDecoder.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigFetchResult.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigFetchStatus.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSnapshot.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSource.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSubscription.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigUpdate.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2Config.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigValue.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/MainThreadDispatcher.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigSnapshotsImpl.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigIdentityBridge.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Manager.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/listeners/QRemoteConfigUpdateListener.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/listeners/QonversionRemoteConfigActivationCallback.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/listeners/QonversionRemoteConfigFetchCallback.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2ConfigTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigsPublicApiTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2DeviceScopeTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt 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 1f0787450..3ca2c068a 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt @@ -97,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 aa727bf4e..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 @@ -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/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..2b1f4f23c --- /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.allKeys + + /** + * 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..20fac0712 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2Config.kt @@ -0,0 +1,47 @@ +package com.qonversion.android.sdk.dto.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi + +private const val REMOTE_CONFIG_V2_UID_MAX_CODE_POINTS = 36 +private val LOWERCASE_SHA256_PATTERN = Regex("^[0-9a-f]{64}$") + +/** + * 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. + * + * @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 projectId numeric project id the served snapshots must belong to. + * @param contextFingerprint the snapshot context fingerprint the gateway resolves for this + * integration. It binds an admitted snapshot to the targeting context it was resolved for, and the + * SDK cannot derive it — the value is server-side keyed. It is a temporary integration hand-off: + * once the gateway returns the fingerprint on session bootstrap, this parameter goes away. + * @throws IllegalArgumentException if any value is malformed. + */ +@ExperimentalQonversionApi +class QRemoteConfigV2Config( + val baseUrl: String, + val environmentUid: String, + val projectId: Long, + val contextFingerprint: String, +) { + 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(projectId > 0) { "Remote Config v2 project id must be positive" } + require(LOWERCASE_SHA256_PATTERN.matches(contextFingerprint)) { + "Remote Config v2 context fingerprint must be 64 lowercase hexadecimal characters" + } + } +} 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/QRemoteConfigManager.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/QRemoteConfigManager.kt index 75cc39122..abcb6f407 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,6 +8,7 @@ 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 @@ -107,6 +108,9 @@ internal class QRemoteConfigManager @Inject constructor( 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() @@ -158,7 +162,10 @@ 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() { + invalidateOnAnyThread {} + identityBridge.targetingInvalidated() + } fun onUserUpdate(updateIdentity: () -> Unit = {}) { // The generation and the UID mutation share one linearization point. @@ -168,6 +175,7 @@ internal class QRemoteConfigManager @Inject constructor( invalidationGeneration.incrementAndGet() userGeneration.incrementAndGet() updateIdentity() + identityBridge.identityScopeChanged() if (Looper.myLooper() == Looper.getMainLooper()) { resetIdentityStateIfNeeded() } else { 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..84d2627b0 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,27 @@ 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) + // 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 = { + manager.updateIdentity(internalConfig.uid, RemoteConfigFetchForceReason.Identify) + } + // 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 = { manager.refreshTargeting() } + } + val lifecycleHandler = AppLifecycleHandler(this) postToMainThread { ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleHandler) } @@ -307,6 +334,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/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/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/RemoteConfigIdentityBridge.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigIdentityBridge.kt new file mode 100644 index 000000000..4fec8a918 --- /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: (() -> 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: (() -> Unit)? = null + + fun identityScopeChanged() = notify(onIdentityScopeChanged) + + fun targetingInvalidated() = notify(onTargetingInvalidated) + + private fun notify(observer: (() -> Unit)?) { + try { + observer?.invoke() + } 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/RemoteConfigV2Factory.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt new file mode 100644 index 000000000..ca5dfd844 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt @@ -0,0 +1,190 @@ +@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.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) + val core = RemoteConfigSnapshotCore(store, bundledRelease(application, primaryConfig.projectKey)) + // One single-threaded worker for BOTH the preloader and the manager: the manager's + // ordering contract (preload installs before a binding change observes the scope) is + // exactly this executor's FIFO ordering. + val worker = Executors.newSingleThreadExecutor(daemonThreadFactory(REMOTE_CONFIG_V2_WORKER_THREAD_NAME)) + val scheduler = scheduler() + val readGuard = 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") }, + ) + val scopeHolder = RemoteConfigV2ScopeHolder() + val clock = RemoteConfigFetchClock { System.currentTimeMillis() } + val coordinator = RemoteConfigFetchCoordinator( + core = core, + transport = transport(application, internalConfig, config, scopeHolder, cache, moshi, logger, clock), + policyStore = PersistentRemoteConfigFetchPolicyStore(cache, moshi), + clock = clock, + random = { Random.Default.nextDouble() }, + scheduler = scheduler, + policy = RemoteConfigFetchPolicy( + minimumFetchIntervalMillis = REMOTE_CONFIG_V2_MINIMUM_FETCH_INTERVAL_MILLIS, + // 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, + ), + ) + return RemoteConfigV2Manager( + core = core, + readGuard = readGuard, + coordinator = coordinator, + options = RemoteConfigV2Options( + projectKey = primaryConfig.projectKey, + environmentUid = config.environmentUid, + projectId = config.projectId, + contextFingerprint = config.contextFingerprint, + ), + scopeHolder = scopeHolder, + scheduler = scheduler, + worker = worker, + mainDispatcher = mainDispatcher, + logger = logger, + ) + } + + @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, + ) + } + }, + clientContextProvider = DeviceRemoteConfigClientContextProvider( + context = application, + sdkVersion = internalConfig.primaryConfig.sdkVersion, + ), + sessionStore = PersistentRemoteConfigSessionStore(cache, moshi), + clock = clock, + 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..46aa75670 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Manager.kt @@ -0,0 +1,323 @@ +@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. + * + * [contextFingerprint] is the server-resolved targeting-context binding an admitted snapshot must + * carry. The SDK cannot compute it (it is keyed server-side), so it is supplied by configuration + * until the gateway hands it over on session bootstrap. + */ +internal data class RemoteConfigV2Options( + val projectKey: String, + val environmentUid: String, + val projectId: Long, + val contextFingerprint: 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) + + var scope: RemoteConfigSnapshotScope? + get() = current.get() + set(value) = current.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's binding change observes the scope. + */ +@Suppress("LongParameterList") +internal class RemoteConfigV2Manager( + private val core: RemoteConfigSnapshotCore, + private val readGuard: RemoteConfigReadGuard, + private val coordinator: RemoteConfigFetchCoordinator, + 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, +) { + /** + * 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) { + 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 + val binding = scope?.let { RemoteConfigFetchBinding(it, expectation()) } + val submitted = submit { + coordinator.transitionTo(binding) + if (binding != 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() { + if (scopeHolder.scope == null) return + 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() = QRemoteConfigSnapshot(readGuard.currentSnapshot()) + + /** + * 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()) + } + } + 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 + delivery.deliver( + QRemoteConfigActivationResult( + changed = changed, + snapshot = QRemoteConfigSnapshot(core.currentSnapshot()), + fetchStatus = fetchStatus, + ), + ) + } + 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) } + } + + 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 expectation() = RemoteConfigSnapshotEnvelopeExpectation( + projectId = options.projectId, + environmentUid = options.environmentUid, + contextFingerprint = options.contextFingerprint, + ) + + 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()) + + 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) + } + + private fun RemoteConfigSnapshotTransitionResult.toFetchStatus(): QRemoteConfigFetchStatus = when (status) { + // Rejected covers a malformed envelope AND a snapshot whose project id, environment or + // context fingerprint does not match the configured expectation. The latter is a + // permanent misconfiguration that otherwise looks exactly like a network failure. + 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 configured project id, " + + "environment uid or context fingerprint, 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/services/BundledRemoteConfigDefaults.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaults.kt index eb14aac5b..cb2ece45a 100644 --- 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 @@ -272,6 +272,18 @@ private fun parsePortableJson(bytes: ByteArray): ParsedJson? = try { 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 -> { 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/dto/remoteconfig/QRemoteConfigV2ConfigTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2ConfigTest.kt new file mode 100644 index 000000000..fefb267ee --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2ConfigTest.kt @@ -0,0 +1,60 @@ +@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.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", + projectId = 42, + contextFingerprint = FINGERPRINT, + ) + + assertEquals("https://gateway.example.com/", config.baseUrl) + assertEquals("production", config.environmentUid) + assertEquals(42L, config.projectId) + assertEquals(FINGERPRINT, config.contextFingerprint) + } + + @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)) }, + "zero project id" to { config(projectId = 0) }, + "negative project id" to { config(projectId = -1) }, + "uppercase fingerprint" to { config(contextFingerprint = FINGERPRINT.uppercase()) }, + "short fingerprint" to { config(contextFingerprint = "a".repeat(63)) }, + "non-hex fingerprint" to { config(contextFingerprint = "z".repeat(64)) }, + ) + + malformed.forEach { (name, build) -> + assertThrows(name, IllegalArgumentException::class.java) { build() } + } + } + + private fun config( + baseUrl: String = "https://gateway.example.com/", + environmentUid: String = "production", + projectId: Long = 42, + contextFingerprint: String = FINGERPRINT, + ) = QRemoteConfigV2Config(baseUrl, environmentUid, projectId, contextFingerprint) + + private companion object { + const val FINGERPRINT = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } +} 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..ac328fc13 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigsPublicApiTest.kt @@ -0,0 +1,554 @@ +@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 snapshot bound to another targeting context is refused`() { + val harness = harness() + harness.serveForeignContextFingerprint() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + + assertEquals(QRemoteConfigFetchStatus.Failed, harness.fetchBlocking().status) + assertNull(harness.core.lastFetchedSnapshot()) + assertEquals(QRemoteConfigSource.Fallback, harness.configs.current.rawValue("count")?.source) + } + + @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 `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() + + 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() + + 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) + } + + @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/RemoteConfigFetchCoordinatorTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinatorTest.kt index e891c009a..8d56dc958 100644 --- 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 @@ -400,13 +400,19 @@ internal class RemoteConfigFetchCoordinatorTest { responseThread.start() assertTrue(parserStarted.await(2, 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(2, TimeUnit.SECONDS)) assertFalse(transitionFinished.await(100, TimeUnit.MILLISECONDS)) releaseParser.countDown() responseThread.join(2_000) 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/RemoteConfigV2TestHarness.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt new file mode 100644 index 000000000..ac3bafc58 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt @@ -0,0 +1,439 @@ +@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.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_DEVICE_INSTALLED_AT = 1_577_836_800L +internal const val HTTP_OK = 200 +internal const val HTTP_NOT_MODIFIED = 304 +internal const val HTTP_SERVER_ERROR = 500 + +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, + 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 snapshotStore = InMemorySnapshotStore() + 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()) + + @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 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 core = RemoteConfigSnapshotCore(snapshotStore, bundled) + + private val readGuard = RemoteConfigReadGuard( + core = core, + preloader = PersistentRemoteConfigReadPreloader(snapshotStore, worker), + buildMode = buildMode, + assertion = { message -> assertions += message }, + telemetry = { event -> guardEvents += event }, + ) + + val coordinator = RemoteConfigFetchCoordinator( + core = core, + transport = RemoteConfigGatewayTransport( + callFactory = httpClient, + baseUrlProvider = { server.url("/").toString() }, + identityProvider = { + scopeHolder.scope?.let { scope -> + RemoteConfigTransportIdentity(scope, "project-token", userUid) + } + }, + clientContextProvider = clientContextProvider, + sessionStore = InMemorySessionStore(), + clock = { System.currentTimeMillis() }, + moshi = Moshi.Builder().build(), + logger = SilentLogger(), + ), + 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, + ), + ) + + val manager = RemoteConfigV2Manager( + core = core, + readGuard = readGuard, + coordinator = coordinator, + options = RemoteConfigV2Options(RC_PROJECT_KEY, RC_ENVIRONMENT, RC_PROJECT_ID, RC_FINGERPRINT), + 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() + } + 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) + + /** Serves releases bound to a different targeting context than the one the SDK expects. */ + fun serveForeignContextFingerprint() { + contextFingerprint.set("b".repeat(RC_FINGERPRINT_LENGTH)) + body.set(defaultBody(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 + } +} + +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 + } +} + +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 +} From 11ec810ff4c3cf8768bafb04e61717217130c940 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Fri, 7 Aug 2026 14:15:32 +0300 Subject: [PATCH 15/30] feat(remote-config): stop requiring a v2 context fingerprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QRemoteConfigV2Config demanded a `contextFingerprint`: the app had to hand the SDK the fingerprint the gateway resolves for it, and an admitted snapshot had to carry exactly that value. Nobody could supply it correctly, because it is not an app-level constant at all. configurator computes it in BuildResolvedSnapshotContextFingerprint (internal/domain/remoteconfigv2/resolved_snapshot.go) by hashing the canonical user uid, randomization id, platform, country, app version, OS version, SDK version, locale, device model, media source / campaign, install and created timestamps, purchases, active experiment uids and custom user properties. It is a per-response tag over mutable targeting inputs, not an identity binding: it rotates on any app or OS update, a language switch, a purchase, a property edit or an experiment enrollment. So the fingerprint is treated as what it is: - it is gone from the public QRemoteConfigV2Config and from the internal RemoteConfigSnapshotEnvelopeExpectation — nothing configures it and nothing compares it against a previous response; - the parser keeps validating its *shape* (64 lowercase hex, required member) and carries it through as an opaque per-response tag; a value that changes between two admissions in the same scope is normal and admitted; - it is still stored with the release, as informational data for logs and bug reports, and it still participates in the release content digest; - the KDoc on the public config, the expectation and the release all state the rule verbatim, so the "pin it across fetches" idea does not get re-invented: pinning it would freeze an identity's config until logout the first time the user updated the app or changed their language. Identity isolation is unchanged and stays where it already lives: each snapshot read travels on a session token minted for exactly one identity, the gateway routes on that session, and the snapshot / session / fetch-policy stores address each identity through its own salted scope digest. No compatibility shim: the surface is @ExperimentalQonversionApi and has never shipped as stable, so the constructor parameter is simply removed. Tests: a rotated fingerprint is admitted end to end through the public API over MockWebServer (and at the core, where the previously admitted release keeps its own tag), every malformed or missing fingerprint is still refused by the parser, and the project / environment admission boundaries are unchanged. Claude-Session: https://claude.ai/code/session_018pHXqfbxkMQJFzUZ3jW4A8 --- .../dto/remoteconfig/QRemoteConfigV2Config.kt | 16 +++---- .../remoteconfig/RemoteConfigSnapshot.kt | 9 ++++ .../RemoteConfigSnapshotEnvelopeParser.kt | 19 +++++--- .../remoteconfig/RemoteConfigV2Factory.kt | 1 - .../remoteconfig/RemoteConfigV2Manager.kt | 20 ++++----- .../remoteconfig/QRemoteConfigV2ConfigTest.kt | 12 +----- .../QRemoteConfigsPublicApiTest.kt | 19 +++++--- .../RemoteConfigFetchCoordinatorTest.kt | 10 ++--- ...teConfigGatewayTransportCoordinatorTest.kt | 1 - .../RemoteConfigSnapshotCoreTest.kt | 43 +++++++++---------- .../RemoteConfigSnapshotEnvelopeParserTest.kt | 30 +++++++++++-- .../remoteconfig/RemoteConfigV2TestHarness.kt | 19 ++++++-- ...PersistentRemoteConfigSnapshotStoreTest.kt | 2 - 13 files changed, 119 insertions(+), 82 deletions(-) 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 index 20fac0712..97fad62c6 100644 --- 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 @@ -3,7 +3,6 @@ package com.qonversion.android.sdk.dto.remoteconfig import com.qonversion.android.sdk.ExperimentalQonversionApi private const val REMOTE_CONFIG_V2_UID_MAX_CODE_POINTS = 36 -private val LOWERCASE_SHA256_PATTERN = Regex("^[0-9a-f]{64}$") /** * Enables the experimental Remote Config v2 snapshot pipeline. @@ -14,14 +13,17 @@ private val LOWERCASE_SHA256_PATTERN = Regex("^[0-9a-f]{64}$") * [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. + * * @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 projectId numeric project id the served snapshots must belong to. - * @param contextFingerprint the snapshot context fingerprint the gateway resolves for this - * integration. It binds an admitted snapshot to the targeting context it was resolved for, and the - * SDK cannot derive it — the value is server-side keyed. It is a temporary integration hand-off: - * once the gateway returns the fingerprint on session bootstrap, this parameter goes away. * @throws IllegalArgumentException if any value is malformed. */ @ExperimentalQonversionApi @@ -29,7 +31,6 @@ class QRemoteConfigV2Config( val baseUrl: String, val environmentUid: String, val projectId: Long, - val contextFingerprint: String, ) { init { require(baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { @@ -40,8 +41,5 @@ class QRemoteConfigV2Config( 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(projectId > 0) { "Remote Config v2 project id must be positive" } - require(LOWERCASE_SHA256_PATTERN.matches(contextFingerprint)) { - "Remote Config v2 context fingerprint must be 64 lowercase hexadecimal characters" - } } } 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 index 0b251a053..2bf78b904 100644 --- 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 @@ -117,6 +117,15 @@ internal class RemoteConfigSnapshotEntry private constructor( } } +/** + * 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, 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 index b1e43fe3c..e05c97f1f 100644 --- 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 @@ -22,10 +22,20 @@ private val PORTABLE_JSON_MAX_INTEGER_BIG = BigInteger.valueOf(PORTABLE_JSON_MAX 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 project and environment the SDK + * was configured for. + * + * 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, - val contextFingerprint: String, ) internal class RemoteConfigSnapshotEnvelope internal constructor( @@ -58,8 +68,7 @@ internal class RemoteConfigSnapshotEnvelopeParser : RemoteConfigSnapshotEnvelope if (!expectation.isValid()) return null return parseBoundBody(body, etag)?.takeIf { envelope -> envelope.projectId == expectation.projectId && - envelope.environmentUid == expectation.environmentUid && - envelope.contextFingerprint == expectation.contextFingerprint + envelope.environmentUid == expectation.environmentUid } } @@ -475,9 +484,7 @@ private class SnapshotJsonReader(private val bytes: ByteArray) { } private fun RemoteConfigSnapshotEnvelopeExpectation.isValid(): Boolean = - projectId in 1..PORTABLE_JSON_MAX_INTEGER && - environmentUid.isValidUid() && - LOWERCASE_SHA256_PATTERN.matches(contextFingerprint) + 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 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 index ca5dfd844..07fe65ceb 100644 --- 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 @@ -116,7 +116,6 @@ internal object RemoteConfigV2Factory { projectKey = primaryConfig.projectKey, environmentUid = config.environmentUid, projectId = config.projectId, - contextFingerprint = config.contextFingerprint, ), scopeHolder = scopeHolder, scheduler = scheduler, 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 index 46aa75670..684ed1223 100644 --- 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 @@ -19,15 +19,15 @@ internal const val REMOTE_CONFIG_V2_DEFAULT_FETCH_TIMEOUT_MILLIS = 5_000L /** * Immutable addressing of one Remote Config v2 integration. * - * [contextFingerprint] is the server-resolved targeting-context binding an admitted snapshot must - * carry. The SDK cannot compute it (it is keyed server-side), so it is supplied by configuration - * until the gateway hands it over on session bootstrap. + * 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. */ internal data class RemoteConfigV2Options( val projectKey: String, val environmentUid: String, val projectId: Long, - val contextFingerprint: String, ) /** @@ -219,7 +219,6 @@ internal class RemoteConfigV2Manager( private fun expectation() = RemoteConfigSnapshotEnvelopeExpectation( projectId = options.projectId, environmentUid = options.environmentUid, - contextFingerprint = options.contextFingerprint, ) private fun scheduleTimeout( @@ -267,9 +266,10 @@ internal class RemoteConfigV2Manager( } private fun RemoteConfigSnapshotTransitionResult.toFetchStatus(): QRemoteConfigFetchStatus = when (status) { - // Rejected covers a malformed envelope AND a snapshot whose project id, environment or - // context fingerprint does not match the configured expectation. The latter is a - // permanent misconfiguration that otherwise looks exactly like a network failure. + // Rejected covers a malformed envelope AND a snapshot whose project id or environment does + // not match the configured expectation. The latter is a permanent misconfiguration 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, @@ -278,8 +278,8 @@ internal class RemoteConfigV2Manager( RemoteConfigSnapshotTransitionStatus.PersistenceFailed -> QRemoteConfigFetchStatus.Failed RemoteConfigSnapshotTransitionStatus.Rejected -> { logger.error( - "Remote Config v2 refused a snapshot: it did not match the configured project id, " + - "environment uid or context fingerprint, or the envelope was malformed", + "Remote Config v2 refused a snapshot: it did not match the configured project id " + + "or environment uid, or the envelope was malformed", ) QRemoteConfigFetchStatus.Failed } 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 index fefb267ee..b4421d3e3 100644 --- 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 @@ -19,13 +19,11 @@ internal class QRemoteConfigV2ConfigTest { baseUrl = "https://gateway.example.com/", environmentUid = "production", projectId = 42, - contextFingerprint = FINGERPRINT, ) assertEquals("https://gateway.example.com/", config.baseUrl) assertEquals("production", config.environmentUid) assertEquals(42L, config.projectId) - assertEquals(FINGERPRINT, config.contextFingerprint) } @Test @@ -37,9 +35,6 @@ internal class QRemoteConfigV2ConfigTest { "over-long environment" to { config(environmentUid = "e".repeat(37)) }, "zero project id" to { config(projectId = 0) }, "negative project id" to { config(projectId = -1) }, - "uppercase fingerprint" to { config(contextFingerprint = FINGERPRINT.uppercase()) }, - "short fingerprint" to { config(contextFingerprint = "a".repeat(63)) }, - "non-hex fingerprint" to { config(contextFingerprint = "z".repeat(64)) }, ) malformed.forEach { (name, build) -> @@ -51,10 +46,5 @@ internal class QRemoteConfigV2ConfigTest { baseUrl: String = "https://gateway.example.com/", environmentUid: String = "production", projectId: Long = 42, - contextFingerprint: String = FINGERPRINT, - ) = QRemoteConfigV2Config(baseUrl, environmentUid, projectId, contextFingerprint) - - private companion object { - const val FINGERPRINT = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - } + ) = QRemoteConfigV2Config(baseUrl, environmentUid, projectId) } 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 index ac328fc13..72760a9f5 100644 --- 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 @@ -172,14 +172,23 @@ internal class QRemoteConfigsPublicApiTest { } @Test - fun `a snapshot bound to another targeting context is refused`() { + 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.serveForeignContextFingerprint() harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + assertEquals(QRemoteConfigFetchStatus.Fetched, harness.fetchBlocking().status) + harness.activateBlocking() - assertEquals(QRemoteConfigFetchStatus.Failed, harness.fetchBlocking().status) - assertNull(harness.core.lastFetchedSnapshot()) - assertEquals(QRemoteConfigSource.Fallback, harness.configs.current.rawValue("count")?.source) + 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 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 index 8d56dc958..d6eaf3626 100644 --- 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 @@ -20,7 +20,6 @@ internal class RemoteConfigFetchCoordinatorTest { expectation = RemoteConfigSnapshotEnvelopeExpectation( projectId = 42, environmentUid = "production", - contextFingerprint = "a".repeat(64), ), ) @@ -114,10 +113,7 @@ internal class RemoteConfigFetchCoordinatorTest { transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 429, retryAfterMillis = 4_000)) coordinator.transitionTo( - binding.copy( - scope = RemoteConfigSnapshotScope("project", "production", "identified-user"), - expectation = binding.expectation.copy(contextFingerprint = "b".repeat(64)), - ), + binding.copy(scope = RemoteConfigSnapshotScope("project", "production", "identified-user")), ) val result = mutableListOf() coordinator.fetch(forceReason = RemoteConfigFetchForceReason.Identify, callback = result::add) @@ -345,7 +341,7 @@ internal class RemoteConfigFetchCoordinatorTest { val nextBinding = binding.copy( scope = RemoteConfigSnapshotScope("project", "production", "canonical-user-next"), - expectation = binding.expectation.copy(contextFingerprint = "b".repeat(64)), + expectation = binding.expectation.copy(projectId = 43), ) coordinator.transitionTo(nextBinding) assertEquals(listOf(RemoteConfigFetchResult.Superseded), oldResults) @@ -354,7 +350,7 @@ internal class RemoteConfigFetchCoordinatorTest { val nextResults = mutableListOf() coordinator.fetch(forceReason = RemoteConfigFetchForceReason.Identify, callback = nextResults::add) - transport.complete(success("wrong-context", 1)) + transport.complete(success("wrong-project", 1)) val transition = (nextResults.single() as RemoteConfigFetchResult.Fetched).transition assertEquals(RemoteConfigSnapshotTransitionStatus.Rejected, transition.status) assertEquals(null, core.lastFetchedSnapshot()) 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 index 7156bbca5..a6ce9d8c5 100644 --- 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 @@ -249,7 +249,6 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { expectation = RemoteConfigSnapshotEnvelopeExpectation( projectId = 42, environmentUid = "production", - contextFingerprint = "a".repeat(64), ), ) val WIRE_BODY = "{\"schema_version\":1,\"project_id\":42,\"environment_uid\":\"production\"," + 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 index 6b0e0c55c..e67c84df2 100644 --- 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 @@ -431,19 +431,17 @@ internal class RemoteConfigSnapshotCoreTest { } @Test - fun `wire admission fences exact identity project environment and context scope`() { + 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, wireExpectation())) assertNull(core.beginAdmission(scopeA, wireExpectation().copy(environmentUid = "staging"))) - for (expectation in listOf( - wireExpectation().copy(projectId = 43), - wireExpectation().copy(contextFingerprint = "b".repeat(64)), - )) { - val token = requireNotNull(core.beginAdmission(scopeA, expectation)) - val result = core.admitCandidate(token, body, strongETag(body)) - assertEquals(RemoteConfigSnapshotTransitionStatus.Rejected, result.status) - } + val token = requireNotNull(core.beginAdmission(scopeA, wireExpectation().copy(projectId = 43))) + + assertEquals( + RemoteConfigSnapshotTransitionStatus.Rejected, + core.admitCandidate(token, body, strongETag(body)).status, + ) assertTrue(store.savedStates.isEmpty()) } @@ -523,15 +521,14 @@ internal class RemoteConfigSnapshotCoreTest { ) assertTrue(secondStore.savedStates.isEmpty()) - val swappedContextBody = wireBody( - "wire-b", - 7, - "\"a\":${wireItem("2")}", - contextFingerprint = "b".repeat(64), - ).encodeToByteArray() + // The expectation travels with the token: one issued for another project cannot admit this + // project's body, even from the core that issued it and on the scope it was issued for. + val foreignProjectToken = requireNotNull( + firstCore.beginAdmission(scopeA, wireExpectation().copy(projectId = 43)), + ) assertEquals( RemoteConfigSnapshotTransitionStatus.Rejected, - firstCore.admitCandidate(token, swappedContextBody, strongETag(swappedContextBody)).status, + firstCore.admitCandidate(foreignProjectToken, validBody, strongETag(validBody)).status, ) assertTrue(firstStore.savedStates.isEmpty()) } @@ -680,7 +677,11 @@ internal class RemoteConfigSnapshotCoreTest { } @Test - fun `same server release can be sequentially admitted for different contexts`() { + 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) @@ -700,7 +701,7 @@ internal class RemoteConfigSnapshotCoreTest { assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation(firstContext))), + requireNotNull(core.beginAdmission(scopeA, wireExpectation())), first, strongETag(first), ).status, @@ -709,7 +710,7 @@ internal class RemoteConfigSnapshotCoreTest { assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation(secondContext))), + requireNotNull(core.beginAdmission(scopeA, wireExpectation())), second, strongETag(second), ).status, @@ -834,11 +835,9 @@ internal class RemoteConfigSnapshotCoreTest { assertTrue(requireNotNull(saved.active).admissionToken > requireNotNull(saved.previous).admissionToken) } - private fun wireExpectation(contextFingerprint: String = "a".repeat(64)) = - RemoteConfigSnapshotEnvelopeExpectation( + private fun wireExpectation() = RemoteConfigSnapshotEnvelopeExpectation( projectId = 42, environmentUid = "production", - contextFingerprint = contextFingerprint, ) private fun wireBody( 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 index f3f081af9..b3a6cfbff 100644 --- 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 @@ -12,7 +12,6 @@ internal class RemoteConfigSnapshotEnvelopeParserTest { private val expectation = RemoteConfigSnapshotEnvelopeExpectation( projectId = 42, environmentUid = "env-production", - contextFingerprint = "a".repeat(64), ) @Test @@ -74,18 +73,36 @@ internal class RemoteConfigSnapshotEnvelopeParserTest { } @Test - fun `expected project environment and context fingerprint are exact admission boundaries`() { + fun `expected project and environment are exact admission boundaries`() { val body = validBody() val mismatches = listOf( expectation.copy(projectId = 43), expectation.copy(environmentUid = "env-staging"), - expectation.copy(contextFingerprint = "b".repeat(64)), ) 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"))) - assertNull(parse(body.replace("a".repeat(64), "b".repeat(64)))) + } + + @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 @@ -229,6 +246,11 @@ internal class RemoteConfigSnapshotEnvelopeParserTest { 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\"," + 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 index ac3bafc58..1587f30ba 100644 --- 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 @@ -199,7 +199,7 @@ internal class RemoteConfigV2Harness( core = core, readGuard = readGuard, coordinator = coordinator, - options = RemoteConfigV2Options(RC_PROJECT_KEY, RC_ENVIRONMENT, RC_PROJECT_ID, RC_FINGERPRINT), + options = RemoteConfigV2Options(RC_PROJECT_KEY, RC_ENVIRONMENT, RC_PROJECT_ID), scopeHolder = scopeHolder, scheduler = timeoutScheduler, worker = worker, @@ -247,10 +247,20 @@ internal class RemoteConfigV2Harness( /** Makes the gateway answer snapshot reads with [statusCode] instead of a release. */ fun serveStatus(statusCode: Int) = snapshotStatusCode.set(statusCode) - /** Serves releases bound to a different targeting context than the one the SDK expects. */ - fun serveForeignContextFingerprint() { + /** + * 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(defaultBody(contextFingerprint.get())) + 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. */ @@ -357,6 +367,7 @@ internal class RemoteConfigV2Harness( private companion object { const val POLL_INTERVAL_MILLIS = 20L + const val ROTATED_RELEASE_NUMBER = 2L } } 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 index e6746b6a2..b599aee6b 100644 --- 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 @@ -512,7 +512,6 @@ internal class PersistentRemoteConfigSnapshotStoreTest { RemoteConfigSnapshotEnvelopeExpectation( projectId = 42, environmentUid = "production", - contextFingerprint = "b".repeat(64), ), ), ) @@ -694,7 +693,6 @@ internal class PersistentRemoteConfigSnapshotStoreTest { expectation = RemoteConfigSnapshotEnvelopeExpectation( projectId = 42, environmentUid = "production", - contextFingerprint = "b".repeat(64), ), ), ).release From f08a58159129dee0160d01c95d2bfc9a0804cc5b Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Fri, 7 Aug 2026 15:08:53 +0300 Subject: [PATCH 16/30] feat(remote-config)!: learn the v2 project id from the session bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The numeric project id a v2 snapshot is admitted against was supplied by the app through QRemoteConfigV2Config, but the app is not its source: the SDK is told it by the gateway's session bootstrap. Asking for it added a public value that could only ever be typed wrong. BREAKING (experimental surface): QRemoteConfigV2Config no longer takes projectId. Callers drop the argument; nothing else changes for them. The id is now learned and pinned per project key + environment by RemoteConfigProjectIdRegistry, durably, next to the session state: the first bootstrap establishes it, every later session must agree, and one that does not is refused as the typed RemoteConfigFetchResponse .ProjectMismatch before a snapshot is ever read — never re-learned. The in-memory pin is authoritative for the process, so a storage failure cannot downgrade a conflict into a silent re-learn. A malformed id is reported apart from a conflict and stays an ordinary failure. Because the pin is established mid-fetch, the envelope expectation moved from the admission claim to the admission itself: beginAdmission takes only the scope, admitCandidate takes the project id the response was served for, and the environment is read from the admitting scope rather than restated. RemoteConfigFetchBinding was exactly a scope plus that expectation, so it is gone and the coordinator binds to the scope. A mismatch feeds the failure backoff. It is permanent until the gateway is fixed and costs a bootstrap round trip each time, and forced fetches bypass the minimum interval but not the backoff gate, so an identify/logout loop cannot turn a misrouted gateway into a request storm. The check this buys is server-vs-server consistency plus trust on first bootstrap, not proof that a snapshot belongs to the project the developer meant to target; QRemoteConfigV2Config's KDoc says so. --- .../dto/remoteconfig/QRemoteConfigV2Config.kt | 14 +- .../RemoteConfigFetchCoordinator.kt | 65 +++++--- .../RemoteConfigGatewaySession.kt | 121 +++++++++++++++ .../RemoteConfigGatewayTransport.kt | 65 ++++++-- .../remoteconfig/RemoteConfigSnapshotCore.kt | 32 ++-- .../RemoteConfigSnapshotEnvelopeParser.kt | 8 +- .../remoteconfig/RemoteConfigV2Factory.kt | 6 +- .../remoteconfig/RemoteConfigV2Manager.kt | 32 ++-- .../remoteconfig/QRemoteConfigV2ConfigTest.kt | 22 ++- .../RemoteConfigFetchCoordinatorTest.kt | 80 +++++----- ...teConfigGatewayTransportCoordinatorTest.kt | 73 +++++++-- .../RemoteConfigGatewayTransportTest.kt | 116 +++++++++++++- .../RemoteConfigSnapshotCoreTest.kt | 146 +++++++++++------- .../remoteconfig/RemoteConfigV2TestHarness.kt | 18 ++- ...PersistentRemoteConfigSnapshotStoreTest.kt | 10 +- 15 files changed, 603 insertions(+), 205 deletions(-) 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 index 97fad62c6..cb66d29a3 100644 --- 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 @@ -20,17 +20,26 @@ private const val REMOTE_CONFIG_V2_UID_MAX_CODE_POINTS = 36 * 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 projectId numeric project id the served snapshots must belong to. * @throws IllegalArgumentException if any value is malformed. */ @ExperimentalQonversionApi class QRemoteConfigV2Config( val baseUrl: String, val environmentUid: String, - val projectId: Long, ) { init { require(baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { @@ -40,6 +49,5 @@ class QRemoteConfigV2Config( 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(projectId > 0) { "Remote Config v2 project id must be positive" } } } 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 index 034e40924..dd7d1e7c4 100644 --- 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 @@ -2,15 +2,6 @@ package com.qonversion.android.sdk.internal.remoteconfig import java.util.ArrayDeque -internal data class RemoteConfigFetchBinding( - val scope: RemoteConfigSnapshotScope, - val expectation: RemoteConfigSnapshotEnvelopeExpectation, -) { - init { - require(scope.environment == expectation.environmentUid) - } -} - internal enum class RemoteConfigFetchForceReason { Build, Identify, @@ -80,12 +71,30 @@ internal data class RemoteConfigFetchRequest( ) internal sealed class RemoteConfigFetchResponse { - data class Success(val body: ByteArray, val etag: String) : 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 { @@ -102,6 +111,7 @@ internal sealed class RemoteConfigFetchResult { data class PolicyPersistenceFailed(val result: RemoteConfigFetchResult) : RemoteConfigFetchResult() data object InvalidNotModified : RemoteConfigFetchResult() data object Superseded : RemoteConfigFetchResult() + data object ProjectMismatch : RemoteConfigFetchResult() } internal class RemoteConfigFetchCoordinator( @@ -119,19 +129,19 @@ internal class RemoteConfigFetchCoordinator( private val deliveryLock = Any() private val pendingDeliveries = ArrayDeque() private var isDrainingDeliveries = false - private var binding: RemoteConfigFetchBinding? = null + private var boundScope: RemoteConfigSnapshotScope? = null private var operationGeneration = 0L private var inFlight: InFlight? = null private var policyState = RemoteConfigFetchPolicyState() - fun transitionTo(nextBinding: RemoteConfigFetchBinding?) { + fun transitionTo(nextScope: RemoteConfigSnapshotScope?) { val persistenceFailure = synchronized(operationLock) { synchronized(lock) { operationGeneration = nextGeneration(operationGeneration) - binding = nextBinding - core.setScope(nextBinding?.scope) - val loaded = nextBinding?.let { - loadPolicyState(RemoteConfigFetchPolicyScope.from(it.scope)) + boundScope = nextScope + core.setScope(nextScope) + val loaded = nextScope?.let { + loadPolicyState(RemoteConfigFetchPolicyScope.from(it)) } ?: LoadedPolicyState(RemoteConfigFetchPolicyState()) policyState = loaded.state val superseded = inFlight?.let { operation -> @@ -179,19 +189,19 @@ internal class RemoteConfigFetchCoordinator( // 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 currentBinding = binding + val currentScope = boundScope ?: return FetchDecision.immediate(operationGeneration, RemoteConfigFetchResult.Superseded) fetchGateLocked(forceReason, nowMillis())?.let { gate -> return FetchDecision.immediate(operationGeneration, gate) } - val admission = core.beginAdmission(currentBinding.scope, currentBinding.expectation) + val admission = core.beginAdmission(currentScope) ?: return FetchDecision.immediate( operationGeneration, RemoteConfigFetchResult.Failed(statusCode = null), ) val operation = InFlight( generation = operationGeneration, - binding = currentBinding, + scope = currentScope, admission = admission, waiters = mutableListOf(), conditionalValidator = core.conditionalRequestValidator(), @@ -306,7 +316,7 @@ internal class RemoteConfigFetchCoordinator( return@synchronized NotModifiedDisposition.Accept } if (operation.didRetryWithoutETag) return@synchronized NotModifiedDisposition.Reject - val refreshedAdmission = core.beginAdmission(operation.binding.scope, operation.binding.expectation) + val refreshedAdmission = core.beginAdmission(operation.scope) ?: return@synchronized NotModifiedDisposition.Reject operation.didRetryWithoutETag = true operation.conditionalValidator = null @@ -320,7 +330,12 @@ internal class RemoteConfigFetchCoordinator( notModifiedDisposition: NotModifiedDisposition, ): ResponseOutcome = when (response) { is RemoteConfigFetchResponse.Success -> { - val transition = core.admitCandidate(operation.admission, response.body, response.etag) + 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( @@ -341,6 +356,10 @@ internal class RemoteConfigFetchCoordinator( 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) { @@ -585,14 +604,14 @@ internal class RemoteConfigFetchCoordinator( private data class InFlight( val generation: Long, - val binding: RemoteConfigFetchBinding, + 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(binding.scope) + val policyScope: RemoteConfigFetchPolicyScope = RemoteConfigFetchPolicyScope.from(scope) } private class FetchWaiter( 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 index 6ec981174..a44fad784 100644 --- 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 @@ -8,6 +8,8 @@ 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 @@ -152,6 +154,125 @@ internal data class PersistedRemoteConfigGatewaySession( 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()) 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 index 3f234e4fa..a5c5b4384 100644 --- 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 @@ -113,6 +113,11 @@ internal fun interface RemoteConfigTransportIdentityProvider { * 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 @@ -131,6 +136,7 @@ internal class RemoteConfigGatewayTransport( private val identityProvider: RemoteConfigTransportIdentityProvider, private val clientContextProvider: RemoteConfigClientContextProvider, private val sessionStore: RemoteConfigSessionStore, + private val projectIds: RemoteConfigProjectIdRegistry, private val clock: RemoteConfigFetchClock, moshi: Moshi, private val logger: Logger, @@ -164,7 +170,35 @@ internal class RemoteConfigGatewayTransport( requestSnapshot(identity, context, minted, request, deliver, allowReBootstrap = false) } } else { - requestSnapshot(identity, context, session, request, deliver, allowReBootstrap = true) + establishProjectId(identity, session)?.let { refusal -> deliver(refusal) } + ?: requestSnapshot(identity, context, session, request, deliver, allowReBootstrap = true) + } + } + + /** + * 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() } } @@ -195,7 +229,7 @@ internal class RemoteConfigGatewayTransport( return } enqueue(httpRequest, deliver) { outcome -> - onSnapshotOutcome(identity, context, request, deliver, allowReBootstrap, outcome) + onSnapshotOutcome(identity, context, session, request, deliver, allowReBootstrap, outcome) } } @@ -203,6 +237,7 @@ internal class RemoteConfigGatewayTransport( private fun onSnapshotOutcome( identity: RemoteConfigTransportIdentity, context: RemoteConfigClientContext, + session: RemoteConfigGatewaySession, request: RemoteConfigFetchRequest, deliver: SingleDelivery, allowReBootstrap: Boolean, @@ -210,7 +245,9 @@ internal class RemoteConfigGatewayTransport( ) { when { outcome == null -> deliver(RemoteConfigFetchResponse.Failure()) - outcome.code == HTTP_OK -> deliver(outcome.asSuccessOrFailure()) + // 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 -> { @@ -255,6 +292,12 @@ internal class RemoteConfigGatewayTransport( deliver(if (outcome?.code == HTTP_OK) RemoteConfigFetchResponse.Failure() else outcome.asFailure()) 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 -> + deliver(refusal) + return@enqueue + } rememberSession(identity.sessionKey, session) onMinted(session) } @@ -450,15 +493,15 @@ internal class RemoteConfigGatewayTransport( val etag: String?, val retryAfterMillis: Long?, ) { - fun asSuccessOrFailure(): RemoteConfigFetchResponse { - val bytes = body - val validator = etag - return if (bytes == null || bytes.isEmpty() || validator.isNullOrEmpty()) { - // An empty body, an over-budget body or a 200 without a strong validator cannot be - // admitted, and none of them is retryable. - RemoteConfigFetchResponse.Failure() + 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.Success(bytes, validator) + RemoteConfigFetchResponse.Failure() } } } 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 index 105120753..e56d212d9 100644 --- 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 @@ -36,14 +36,12 @@ internal class RemoteConfigSnapshotAdmissionToken private constructor( ordinal: Long, scope: RemoteConfigSnapshotScope, scopeGeneration: Long, - expectation: RemoteConfigSnapshotEnvelopeExpectation, ) = RemoteConfigSnapshotAdmissionToken( ownerNonce = ownerNonce, admission = BoundRemoteConfigSnapshotAdmission( ordinal = ordinal, scope = scope, scopeGeneration = scopeGeneration, - expectation = expectation, ), ) } @@ -84,7 +82,6 @@ internal data class BoundRemoteConfigSnapshotAdmission( val ordinal: Long, val scope: RemoteConfigSnapshotScope, val scopeGeneration: Long, - val expectation: RemoteConfigSnapshotEnvelopeExpectation, ) internal data class RemoteConfigConditionalRequestValidator( @@ -301,19 +298,22 @@ internal class RemoteConfigSnapshotCore( synchronized(lock) { observers.remove(token) } } - fun beginAdmission( - scope: RemoteConfigSnapshotScope, - expectation: RemoteConfigSnapshotEnvelopeExpectation, - ): RemoteConfigSnapshotAdmissionToken? = + /** + * 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) { - if (expectation.environmentUid != scope.environment) return@synchronized null val admission = issueAdmissionLocked(scope) ?: return@synchronized null RemoteConfigSnapshotAdmissionToken.issue( ownerNonce = admissionOwnerNonce, ordinal = admission.ordinal, scope = scope, scopeGeneration = admission.scopeGeneration, - expectation = expectation, ) } @@ -360,11 +360,19 @@ internal class RemoteConfigSnapshotCore( ) } + /** + * 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) @@ -377,7 +385,11 @@ internal class RemoteConfigSnapshotCore( if (!tokenIsCurrent) { return RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.Rejected) } - val envelope = envelopeParser.parse(body, etag, admission.expectation) + 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, 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 index e05c97f1f..ee9a4a2d1 100644 --- 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 @@ -23,8 +23,12 @@ 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 project and environment the SDK - * was configured for. + * 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 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 index 07fe65ceb..a23a11d44 100644 --- 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 @@ -72,7 +72,7 @@ internal object RemoteConfigV2Factory { val store = PersistentRemoteConfigSnapshotStore(cache, moshi) val core = RemoteConfigSnapshotCore(store, bundledRelease(application, primaryConfig.projectKey)) // One single-threaded worker for BOTH the preloader and the manager: the manager's - // ordering contract (preload installs before a binding change observes the scope) is + // 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() @@ -115,7 +115,6 @@ internal object RemoteConfigV2Factory { options = RemoteConfigV2Options( projectKey = primaryConfig.projectKey, environmentUid = config.environmentUid, - projectId = config.projectId, ), scopeHolder = scopeHolder, scheduler = scheduler, @@ -161,6 +160,9 @@ internal object RemoteConfigV2Factory { 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, moshi = moshi, logger = logger, 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 index 684ed1223..f79fdc559 100644 --- 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 @@ -23,11 +23,13 @@ internal const val REMOTE_CONFIG_V2_DEFAULT_FETCH_TIMEOUT_MILLIS = 5_000L * 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, - val projectId: Long, ) /** @@ -69,7 +71,7 @@ internal fun interface RemoteConfigMainDispatcher { * - 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's binding change observes the scope. + * installs the loaded state before the coordinator observes the new scope. */ @Suppress("LongParameterList") internal class RemoteConfigV2Manager( @@ -98,10 +100,9 @@ internal class RemoteConfigV2Manager( // concurrent fetch reads the new identity and admits its snapshot into the old store. readGuard.transitionScopeBeforeSdkReady(scope) scopeHolder.scope = scope - val binding = scope?.let { RemoteConfigFetchBinding(it, expectation()) } val submitted = submit { - coordinator.transitionTo(binding) - if (binding != null) forceFetch(forceReason) + coordinator.transitionTo(scope) + if (scope != null) forceFetch(forceReason) } if (!submitted) logger.debug("Remote Config v2 could not apply an identity change") } @@ -216,11 +217,6 @@ internal class RemoteConfigV2Manager( null } - private fun expectation() = RemoteConfigSnapshotEnvelopeExpectation( - projectId = options.projectId, - environmentUid = options.environmentUid, - ) - private fun scheduleTimeout( timeoutMillis: Long?, delivery: SingleDelivery, @@ -263,13 +259,17 @@ internal class RemoteConfigV2Manager( 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 whose project id or environment does - // not match the configured expectation. The latter is a permanent misconfiguration 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. + // 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, @@ -278,8 +278,8 @@ internal class RemoteConfigV2Manager( RemoteConfigSnapshotTransitionStatus.PersistenceFailed -> QRemoteConfigFetchStatus.Failed RemoteConfigSnapshotTransitionStatus.Rejected -> { logger.error( - "Remote Config v2 refused a snapshot: it did not match the configured project id " + - "or environment uid, or the envelope was malformed", + "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 } 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 index b4421d3e3..bc215250d 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -18,12 +19,10 @@ internal class QRemoteConfigV2ConfigTest { val config = QRemoteConfigV2Config( baseUrl = "https://gateway.example.com/", environmentUid = "production", - projectId = 42, ) assertEquals("https://gateway.example.com/", config.baseUrl) assertEquals("production", config.environmentUid) - assertEquals(42L, config.projectId) } @Test @@ -33,8 +32,6 @@ internal class QRemoteConfigV2ConfigTest { "scheme-less base url" to { config(baseUrl = "//gateway.example.com") }, "empty environment" to { config(environmentUid = "") }, "over-long environment" to { config(environmentUid = "e".repeat(37)) }, - "zero project id" to { config(projectId = 0) }, - "negative project id" to { config(projectId = -1) }, ) malformed.forEach { (name, build) -> @@ -42,9 +39,22 @@ internal class QRemoteConfigV2ConfigTest { } } + @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"), + QRemoteConfigV2Config::class.java.declaredFields.map { it.name }.toSet(), + ) + } + private fun config( baseUrl: String = "https://gateway.example.com/", environmentUid: String = "production", - projectId: Long = 42, - ) = QRemoteConfigV2Config(baseUrl, environmentUid, projectId) + ) = QRemoteConfigV2Config(baseUrl, environmentUid) } 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 index d6eaf3626..1ea0ff370 100644 --- 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 @@ -15,19 +15,12 @@ import java.util.concurrent.atomic.AtomicBoolean internal class RemoteConfigFetchCoordinatorTest { private val scope = RemoteConfigSnapshotScope("project", "production", "canonical-user") - private val binding = RemoteConfigFetchBinding( - scope = scope, - expectation = RemoteConfigSnapshotEnvelopeExpectation( - projectId = 42, - environmentUid = "production", - ), - ) @Test fun `concurrent fetches coalesce into one transport request`() { val transport = RecordingTransport() val coordinator = coordinator(transport) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val results = mutableListOf() coordinator.fetch(callback = results::add) @@ -49,7 +42,7 @@ internal class RemoteConfigFetchCoordinatorTest { policyStore = policyStore, policy = RemoteConfigFetchPolicy(minimumFetchIntervalMillis = 60_000), ) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) coordinator.fetch(callback = {}) transport.complete(success("first", 1)) @@ -73,7 +66,7 @@ internal class RemoteConfigFetchCoordinatorTest { maximumBackoffMillis = 10_000, ) val first = coordinator(transport, clock, policyStore, policy) - first.transitionTo(binding) + first.transitionTo(scope) first.fetch(callback = {}) transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 429, retryAfterMillis = 4_000)) @@ -84,7 +77,7 @@ internal class RemoteConfigFetchCoordinatorTest { val restartedTransport = RecordingTransport() val restarted = coordinator(restartedTransport, clock, policyStore, policy) - restarted.transitionTo(binding) + restarted.transitionTo(scope) val beforeDeadline = mutableListOf() restarted.fetch(callback = beforeDeadline::add) assertTrue(beforeDeadline.single() is RemoteConfigFetchResult.Backoff) @@ -108,13 +101,11 @@ internal class RemoteConfigFetchCoordinatorTest { maximumBackoffMillis = 10_000, ), ) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) coordinator.fetch(callback = {}) transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 429, retryAfterMillis = 4_000)) - coordinator.transitionTo( - binding.copy(scope = RemoteConfigSnapshotScope("project", "production", "identified-user")), - ) + coordinator.transitionTo(RemoteConfigSnapshotScope("project", "production", "identified-user")) val result = mutableListOf() coordinator.fetch(forceReason = RemoteConfigFetchForceReason.Identify, callback = result::add) @@ -139,7 +130,7 @@ internal class RemoteConfigFetchCoordinatorTest { maximumBackoffMillis = 1_500, ), ) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) coordinator.fetch(callback = {}) transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 500)) assertEquals( @@ -185,7 +176,7 @@ internal class RemoteConfigFetchCoordinatorTest { scheduler = scheduler, policy = RemoteConfigFetchPolicy(minimumFetchIntervalMillis = 0, timeoutMillis = 100), ) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) core.acceptCandidate(scope, release("active", 1, "1")) core.activate() val results = mutableListOf() @@ -212,7 +203,7 @@ internal class RemoteConfigFetchCoordinatorTest { scheduler = scheduler, policy = RemoteConfigFetchPolicy(minimumFetchIntervalMillis = 0, timeoutMillis = 100), ) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val first = mutableListOf() val second = mutableListOf() coordinator.fetch(callback = first::add) @@ -244,7 +235,7 @@ internal class RemoteConfigFetchCoordinatorTest { scheduler = scheduler, policy = RemoteConfigFetchPolicy(minimumFetchIntervalMillis = 0, timeoutMillis = 100), ) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val results = mutableListOf() coordinator.fetch(callback = results::add) @@ -259,7 +250,7 @@ internal class RemoteConfigFetchCoordinatorTest { val transport = RecordingTransport() val core = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundledRelease = null) val coordinator = coordinator(transport = transport, core = core) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val first = success("first", 1) coordinator.fetch(callback = {}) transport.complete(first) @@ -278,7 +269,7 @@ internal class RemoteConfigFetchCoordinatorTest { val transport = RecordingTransport() val core = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundledRelease = null) val coordinator = coordinator(transport = transport, core = core) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) coordinator.fetch(callback = {}) transport.complete(success("active", 1)) core.activate() @@ -294,7 +285,7 @@ internal class RemoteConfigFetchCoordinatorTest { val transport = RecordingTransport() val core = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundledRelease = null) val coordinator = coordinator(transport = transport, core = core) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val canonical = success("canonical", 1) coordinator.fetch(callback = {}) transport.complete(canonical) @@ -315,7 +306,7 @@ internal class RemoteConfigFetchCoordinatorTest { val transport = RecordingTransport() val core = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundledRelease = null) val coordinator = coordinator(transport = transport, core = core) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val results = mutableListOf() coordinator.fetch(callback = results::add) @@ -335,37 +326,36 @@ internal class RemoteConfigFetchCoordinatorTest { val transport = RecordingTransport() val core = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundledRelease = null) val coordinator = coordinator(transport = transport, core = core) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val oldResults = mutableListOf() coordinator.fetch(callback = oldResults::add) - val nextBinding = binding.copy( - scope = RemoteConfigSnapshotScope("project", "production", "canonical-user-next"), - expectation = binding.expectation.copy(projectId = 43), - ) - coordinator.transitionTo(nextBinding) + 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) - transport.complete(success("wrong-project", 1)) + // 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 binding can be explicitly generation fenced on identify`() { + 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(binding) + coordinator.transitionTo(scope) val results = mutableListOf() coordinator.fetch(callback = results::add) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) transport.complete(success("stale", 1)) assertEquals(listOf(RemoteConfigFetchResult.Superseded), results) @@ -388,7 +378,7 @@ internal class RemoteConfigFetchCoordinatorTest { envelopeParser = parser, ) val coordinator = coordinator(transport = transport, core = core) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val events = Collections.synchronizedList(mutableListOf()) coordinator.fetch { events += "callback" } @@ -420,7 +410,7 @@ internal class RemoteConfigFetchCoordinatorTest { fun `one throwing coalesced callback cannot starve the remaining waiters`() { val transport = RecordingTransport() val coordinator = coordinator(transport) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val delivered = mutableListOf() coordinator.fetch { throw AssertionError("consumer failure") } coordinator.fetch(callback = delivered::add) @@ -434,7 +424,7 @@ internal class RemoteConfigFetchCoordinatorTest { fun `reentrant identity transition converts every remaining claimed callback to Superseded`() { val transport = RecordingTransport() val coordinator = coordinator(transport) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val first = mutableListOf() val second = mutableListOf() coordinator.fetch { result -> @@ -453,7 +443,7 @@ internal class RemoteConfigFetchCoordinatorTest { fun `callback delivery holds no coordinator monitor needed by a concurrent transition`() { val transport = RecordingTransport() val coordinator = coordinator(transport) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val transitionCompletedInsideCallback = AtomicBoolean(false) coordinator.fetch { val completed = CountDownLatch(1) @@ -480,7 +470,7 @@ internal class RemoteConfigFetchCoordinatorTest { maximumBackoffMillis = 10_000, ) val coordinator = coordinator(transport, clock, policyStore, policy) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val failed = mutableListOf() coordinator.fetch(callback = failed::add) transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 500)) @@ -492,7 +482,7 @@ internal class RemoteConfigFetchCoordinatorTest { val restartedTransport = RecordingTransport() val restarted = coordinator(restartedTransport, clock, policyStore, policy) - restarted.transitionTo(binding) + restarted.transitionTo(scope) restarted.fetch(callback = {}) assertEquals(1, restartedTransport.requests.size) } @@ -500,7 +490,7 @@ internal class RemoteConfigFetchCoordinatorTest { @Test fun `transport and timeout scheduler failures still complete or continue the operation`() { val transportFailure = coordinator(RemoteConfigFetchTransport { _, _ -> error("transport") }) - transportFailure.transitionTo(binding) + transportFailure.transitionTo(scope) val failed = mutableListOf() transportFailure.fetch(callback = failed::add) assertTrue(failed.single() is RemoteConfigFetchResult.Failed) @@ -511,7 +501,7 @@ internal class RemoteConfigFetchCoordinatorTest { scheduler = RemoteConfigFetchScheduler { _, _ -> error("scheduler") }, policy = RemoteConfigFetchPolicy(minimumFetchIntervalMillis = 0, timeoutMillis = 100), ) - schedulerFailure.transitionTo(binding) + schedulerFailure.transitionTo(scope) val recovered = mutableListOf() schedulerFailure.fetch(callback = recovered::add) transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 400)) @@ -539,7 +529,7 @@ internal class RemoteConfigFetchCoordinatorTest { maximumBackoffMillis = 10_000, ), ) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val result = mutableListOf() coordinator.fetch(callback = result::add) @@ -572,7 +562,7 @@ internal class RemoteConfigFetchCoordinatorTest { maximumBackoffMillis = 10_000, ), ) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) coordinator.fetch(callback = {}) transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 500)) assertTrue( @@ -605,9 +595,9 @@ internal class RemoteConfigFetchCoordinatorTest { ) } - private fun success(uid: String, number: Long): RemoteConfigFetchResponse.Success { + private fun success(uid: String, number: Long, projectId: Long = 42): RemoteConfigFetchResponse.Success { val body = wireBody(uid, number).encodeToByteArray() - return RemoteConfigFetchResponse.Success(body, strongETag(body)) + return RemoteConfigFetchResponse.Success(body, strongETag(body), projectId) } private fun wireBody(uid: String, number: Long) = 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 index a6ce9d8c5..6d1c5166e 100644 --- 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 @@ -14,6 +14,7 @@ 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 @@ -49,7 +50,7 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { fun `server bytes reach durable admission unchanged`() { val core = core() val coordinator = coordinator(core) - coordinator.transitionTo(BINDING) + coordinator.transitionTo(SCOPE) val body = WIRE_BODY.toByteArray(Charsets.UTF_8) server.enqueue(sessionResponse()) server.enqueue(snapshotResponse(body, strongETag(body))) @@ -71,7 +72,7 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { fun `304 is recovered against the current head instead of re-admitting`() { val core = core() val coordinator = coordinator(core) - coordinator.transitionTo(BINDING) + coordinator.transitionTo(SCOPE) val body = WIRE_BODY.toByteArray(Charsets.UTF_8) server.enqueue(sessionResponse()) server.enqueue(snapshotResponse(body, strongETag(body))) @@ -89,11 +90,58 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { 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(BINDING) + coordinator.transitionTo(SCOPE) server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)) val latch = CountDownLatch(1) @@ -111,10 +159,13 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { assertNotNull(server.takeRequest(AWAIT_SECONDS, TimeUnit.SECONDS)) } - private fun fetch(coordinator: RemoteConfigFetchCoordinator): RemoteConfigFetchResult { + private fun fetch( + coordinator: RemoteConfigFetchCoordinator, + forceReason: RemoteConfigFetchForceReason? = null, + ): RemoteConfigFetchResult { val latch = CountDownLatch(1) var result: RemoteConfigFetchResult? = null - coordinator.fetch { fetchResult -> + coordinator.fetch(forceReason) { fetchResult -> result = fetchResult latch.countDown() } @@ -152,15 +203,16 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { ) }, sessionStore = InMemorySessionStore(), + projectIds = RemoteConfigProjectIdRegistry(InMemoryProjectIdStore()), clock = { CLOCK_MILLIS }, moshi = Moshi.Builder().build(), logger = SilentLogger(), ) - private fun sessionResponse() = MockResponse() + private fun sessionResponse(projectId: Long = 42) = MockResponse() .setResponseCode(200) .setBody( - "{\"session_token\":\"qrcs1.session-secret\",\"project_id\":42," + + "{\"session_token\":\"qrcs1.session-secret\",\"project_id\":$projectId," + "\"environment\":\"prod\",\"expires_at\":\"2030-01-01T00:00:00Z\"}", ) @@ -244,13 +296,6 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { const val AWAIT_SECONDS = 10L const val CLOCK_MILLIS = 1_000_000L val SCOPE = RemoteConfigSnapshotScope("project", "production", "canonical-user") - val BINDING = RemoteConfigFetchBinding( - scope = SCOPE, - expectation = RemoteConfigSnapshotEnvelopeExpectation( - projectId = 42, - environmentUid = "production", - ), - ) 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')}\"," + 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 index d9b944211..a58ffaf03 100644 --- 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 @@ -202,10 +202,13 @@ internal class RemoteConfigGatewayTransportTest { fetch(RemoteConfigFetchRequest(), transport) server.takeRequest() server.takeRequest() + // The session record plus the project id the bootstrap established. val keysAfterFirstIdentity = cache.strings.keys.toSet() - assertEquals(1, keysAfterFirstIdentity.size) - assertFalse(keysAfterFirstIdentity.single().contains(USER_A)) - assertFalse(keysAfterFirstIdentity.single().contains(PROJECT_TOKEN)) + 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)) @@ -217,11 +220,102 @@ internal class RemoteConfigGatewayTransportTest { assertEquals("{\"user_uid\":\"$USER_B\"}", bootstrap.body.readUtf8()) val snapshot = server.takeRequest() assertEquals(OTHER_SESSION_TOKEN, snapshot.getHeader(REMOTE_CONFIG_SESSION_HEADER)) - assertEquals(2, cache.strings.size) + // 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) @@ -434,12 +528,14 @@ internal class RemoteConfigGatewayTransportTest { private fun transport( sessionStore: RemoteConfigSessionStore = store(), maxSnapshotBodyBytes: Long = REMOTE_CONFIG_SNAPSHOT_BODY_MAX_BYTES, + projectIds: RemoteConfigProjectIdRegistry = registry(), ) = RemoteConfigGatewayTransport( callFactory = client, baseUrlProvider = { server.url("/").toString() }, identityProvider = { identity }, clientContextProvider = { clientContext }, sessionStore = sessionStore, + projectIds = projectIds, clock = clock, moshi = Moshi.Builder().build(), logger = logger, @@ -448,17 +544,22 @@ internal class RemoteConfigGatewayTransportTest { 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 = 42, + projectId = projectId, environment = "prod", expiresAtMillis = expiresAtMillis, ), @@ -466,11 +567,11 @@ internal class RemoteConfigGatewayTransportTest { ) } - private fun sessionResponse(token: String) = MockResponse() + private fun sessionResponse(token: String, projectId: Long = PROJECT_ID) = MockResponse() .setResponseCode(200) .setHeader("Cache-Control", "private, no-store") .setBody( - "{\"session_token\":\"$token\",\"project_id\":42,\"environment\":\"prod\"," + + "{\"session_token\":\"$token\",\"project_id\":$projectId,\"environment\":\"prod\"," + "\"expires_at\":\"2030-01-01T00:00:00Z\"}", ) @@ -547,6 +648,7 @@ internal class RemoteConfigGatewayTransportTest { 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" 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 index e67c84df2..0f7cff2a5 100644 --- 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 @@ -419,8 +419,8 @@ internal class RemoteConfigSnapshotCoreTest { values = "\"good\":${wireItem("1")},\"bad\":${wireItem("{\"x\":1,\"x\":2}")}", ) - val result = core.admitCandidate( - admissionToken = requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + val result = core.admitWire( + admissionToken = requireNotNull(core.beginAdmission(scopeA)), body = malformed.encodeToByteArray(), etag = strongETag(malformed.encodeToByteArray()), ) @@ -434,13 +434,28 @@ internal class RemoteConfigSnapshotCoreTest { 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, wireExpectation())) - assertNull(core.beginAdmission(scopeA, wireExpectation().copy(environmentUid = "staging"))) - val token = requireNotNull(core.beginAdmission(scopeA, wireExpectation().copy(projectId = 43))) + 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.admitCandidate(token, body, strongETag(body)).status, + 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()) } @@ -456,8 +471,8 @@ internal class RemoteConfigSnapshotCoreTest { values = "\"kept\":${wireItem("2", immediate = true)}", ).encodeToByteArray() - val result = core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + val result = core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), body, strongETag(body), ) @@ -487,10 +502,10 @@ internal class RemoteConfigSnapshotCoreTest { } val raceCore = RemoteConfigSnapshotCore(raceStore, bundled, blockingParser) raceCore.setScope(scopeA) - val admissionToken = requireNotNull(raceCore.beginAdmission(scopeA, expectation)) + val admissionToken = requireNotNull(raceCore.beginAdmission(scopeA)) val result = AtomicReference() val admissionThread = Thread { - result.set(raceCore.admitCandidate(admissionToken, body, etag)) + result.set(raceCore.admitWire(admissionToken, body, etag)) } admissionThread.start() assertTrue(parserStarted.await(2, TimeUnit.SECONDS)) @@ -505,30 +520,33 @@ internal class RemoteConfigSnapshotCoreTest { } @Test - fun `admission token is opaque to another core and carries its original expectation`() { + 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, wireExpectation())) + val token = requireNotNull(firstCore.beginAdmission(scopeA)) val validBody = wireBody("wire-a", 7, "\"a\":${wireItem("1")}").encodeToByteArray() assertEquals( RemoteConfigSnapshotTransitionStatus.Rejected, - secondCore.admitCandidate(token, validBody, strongETag(validBody)).status, + secondCore.admitWire(token, validBody, strongETag(validBody)).status, ) assertTrue(secondStore.savedStates.isEmpty()) - // The expectation travels with the token: one issued for another project cannot admit this - // project's body, even from the core that issued it and on the scope it was issued for. - val foreignProjectToken = requireNotNull( - firstCore.beginAdmission(scopeA, wireExpectation().copy(projectId = 43)), - ) + // 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.admitCandidate(foreignProjectToken, validBody, strongETag(validBody)).status, + firstCore.admitWire( + requireNotNull(firstCore.beginAdmission(scopeA)), + validBody, + strongETag(validBody), + projectId = 43, + ).status, ) assertTrue(firstStore.savedStates.isEmpty()) } @@ -560,15 +578,15 @@ internal class RemoteConfigSnapshotCoreTest { raceCore.setScope(scopeA) val observed = mutableListOf() raceCore.addUpdateObserver(observed::add) - val superseded = requireNotNull(raceCore.beginAdmission(scopeA, expectation)) + val superseded = requireNotNull(raceCore.beginAdmission(scopeA)) val result = AtomicReference() val admissionThread = Thread { - result.set(raceCore.admitCandidate(superseded, immediateBody, strongETag(immediateBody))) + result.set(raceCore.admitWire(superseded, immediateBody, strongETag(immediateBody))) } admissionThread.start() assertTrue(parserStarted.await(2, TimeUnit.SECONDS)) - requireNotNull(raceCore.beginAdmission(scopeA, expectation)) + requireNotNull(raceCore.beginAdmission(scopeA)) releaseParser.countDown() admissionThread.join(2_000) @@ -614,12 +632,11 @@ internal class RemoteConfigSnapshotCoreTest { 2, "\"a\":${wireItem("2", immediate = true)}", ).encodeToByteArray() - val expectation = wireExpectation() - val supersededToken = requireNotNull(raceCore.beginAdmission(scopeA, expectation)) + val supersededToken = requireNotNull(raceCore.beginAdmission(scopeA)) val supersededResult = AtomicReference() val supersededThread = Thread { supersededResult.set( - raceCore.admitCandidate( + raceCore.admitWire( supersededToken, supersededBody, strongETag(supersededBody), @@ -629,7 +646,7 @@ internal class RemoteConfigSnapshotCoreTest { supersededThread.start() assertTrue(supersededCommitFinished.await(2, TimeUnit.SECONDS)) - requireNotNull(raceCore.beginAdmission(scopeA, expectation)) + requireNotNull(raceCore.beginAdmission(scopeA)) releaseFirstDelivery.countDown() blockingDeliveryThread.join(2_000) supersededThread.join(2_000) @@ -645,8 +662,8 @@ internal class RemoteConfigSnapshotCoreTest { val current = wireBody("release-seven", 7, "\"a\":${wireItem("7")}").encodeToByteArray() assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, - core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), current, strongETag(current), ).status, @@ -655,8 +672,8 @@ internal class RemoteConfigSnapshotCoreTest { assertEquals( RemoteConfigSnapshotTransitionStatus.Rejected, - core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), rollback, strongETag(rollback), ).status, @@ -667,8 +684,8 @@ internal class RemoteConfigSnapshotCoreTest { val secondRollback = wireBody("release-five", 5, "\"a\":${wireItem("5")}").encodeToByteArray() assertEquals( RemoteConfigSnapshotTransitionStatus.Rejected, - core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), secondRollback, strongETag(secondRollback), ).status, @@ -700,8 +717,8 @@ internal class RemoteConfigSnapshotCoreTest { assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, - core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), first, strongETag(first), ).status, @@ -709,8 +726,8 @@ internal class RemoteConfigSnapshotCoreTest { core.activate() assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, - core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), second, strongETag(second), ).status, @@ -724,44 +741,44 @@ internal class RemoteConfigSnapshotCoreTest { @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, wireExpectation())) - val newToken = requireNotNull(core.beginAdmission(scopeA, wireExpectation())) + 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.admitCandidate(newToken, newBody, strongETag(newBody)).status, + core.admitWire(newToken, newBody, strongETag(newBody)).status, ) assertEquals( RemoteConfigSnapshotTransitionStatus.Rejected, - core.admitCandidate(oldToken, oldBody, strongETag(oldBody)).status, + core.admitWire(oldToken, oldBody, strongETag(oldBody)).status, ) assertEquals("new", core.lastFetchedSnapshot()?.releaseUid) - val laterToken = requireNotNull(core.beginAdmission(scopeA, wireExpectation())) + val laterToken = requireNotNull(core.beginAdmission(scopeA)) val laterBody = wireBody("later", 7, "\"a\":${wireItem("3")}").encodeToByteArray() assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, - core.admitCandidate(laterToken, laterBody, strongETag(laterBody)).status, + 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, wireExpectation())) + val orderedOldToken = requireNotNull(orderedCore.beginAdmission(scopeB)) assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, - orderedCore.admitCandidate( + orderedCore.admitWire( orderedOldToken, oldBody, strongETag(oldBody), ).status, ) - val orderedNewToken = requireNotNull(orderedCore.beginAdmission(scopeB, wireExpectation())) + val orderedNewToken = requireNotNull(orderedCore.beginAdmission(scopeB)) assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, - orderedCore.admitCandidate( + orderedCore.admitWire( orderedNewToken, newBody, strongETag(newBody), @@ -774,20 +791,20 @@ internal class RemoteConfigSnapshotCoreTest { 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, wireExpectation())) + val committedToken = requireNotNull(core.beginAdmission(scopeA)) assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, - core.admitCandidate(committedToken, body, strongETag(body)).status, + 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, wireExpectation())) + val restartedToken = requireNotNull(restarted.beginAdmission(scopeA)) val restartedBody = wireBody("wire-restarted", 7, "\"a\":${wireItem("2")}").encodeToByteArray() assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, - restarted.admitCandidate(restartedToken, restartedBody, strongETag(restartedBody)).status, + restarted.admitWire(restartedToken, restartedBody, strongETag(restartedBody)).status, ) assertTrue(requireNotNull(store.states.getValue(scopeA).candidate).admissionToken > committedOrdinal) @@ -809,16 +826,16 @@ internal class RemoteConfigSnapshotCoreTest { assertEquals( RemoteConfigSnapshotTransitionStatus.Activated, - core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), first, strongETag(first), ).status, ) assertEquals( RemoteConfigSnapshotTransitionStatus.Activated, - core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), second, strongETag(second), ).status, @@ -836,17 +853,30 @@ internal class RemoteConfigSnapshotCoreTest { } private fun wireExpectation() = RemoteConfigSnapshotEnvelopeExpectation( - projectId = 42, + 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\":42,\"environment_uid\":\"production\"," + + "{\"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}}" @@ -914,4 +944,8 @@ internal class RemoteConfigSnapshotCoreTest { return true } } + + private companion object { + const val WIRE_PROJECT_ID = 42L + } } 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 index 1587f30ba..1062d2155 100644 --- 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 @@ -179,6 +179,7 @@ internal class RemoteConfigV2Harness( }, clientContextProvider = clientContextProvider, sessionStore = InMemorySessionStore(), + projectIds = RemoteConfigProjectIdRegistry(InMemoryProjectIdStore()), clock = { System.currentTimeMillis() }, moshi = Moshi.Builder().build(), logger = SilentLogger(), @@ -199,7 +200,7 @@ internal class RemoteConfigV2Harness( core = core, readGuard = readGuard, coordinator = coordinator, - options = RemoteConfigV2Options(RC_PROJECT_KEY, RC_ENVIRONMENT, RC_PROJECT_ID), + options = RemoteConfigV2Options(RC_PROJECT_KEY, RC_ENVIRONMENT), scopeHolder = scopeHolder, scheduler = timeoutScheduler, worker = worker, @@ -442,6 +443,21 @@ internal class InMemorySessionStore : RemoteConfigSessionStore { } } +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 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 index b599aee6b..fa0b72055 100644 --- 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 @@ -506,15 +506,7 @@ internal class PersistentRemoteConfigSnapshotStoreTest { assertEquals(7L, recovered?.latestAdmissionToken) val restartedCore = RemoteConfigSnapshotCore(store(), bundledRelease = null) restartedCore.setScope(userA) - assertNotNull( - restartedCore.beginAdmission( - userA, - RemoteConfigSnapshotEnvelopeExpectation( - projectId = 42, - environmentUid = "production", - ), - ), - ) + assertNotNull(restartedCore.beginAdmission(userA)) } @Test From 3cba24ad50e4d7f0c6507236cb0ca7d3163f1d7c Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Fri, 7 Aug 2026 16:14:57 +0300 Subject: [PATCH 17/30] test: give the identity-transition ordering test load-proof waits --- .../remoteconfig/RemoteConfigFetchCoordinatorTest.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 index 1ea0ff370..0263f1dad 100644 --- 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 @@ -368,7 +368,7 @@ internal class RemoteConfigFetchCoordinatorTest { val releaseParser = CountDownLatch(1) val parser = RemoteConfigSnapshotEnvelopeDecoder { body, etag, expectation -> parserStarted.countDown() - assertTrue(releaseParser.await(2, TimeUnit.SECONDS)) + assertTrue(releaseParser.await(30, TimeUnit.SECONDS)) RemoteConfigSnapshotEnvelopeParser().parse(body, etag, expectation) } val transport = RecordingTransport() @@ -384,7 +384,7 @@ internal class RemoteConfigFetchCoordinatorTest { val responseThread = Thread { transport.complete(success("admitted", 1)) } responseThread.start() - assertTrue(parserStarted.await(2, TimeUnit.SECONDS)) + assertTrue(parserStarted.await(30, TimeUnit.SECONDS)) val transitionFinished = CountDownLatch(1) val transitionEntered = CountDownLatch(1) val transitionThread = Thread { @@ -398,11 +398,11 @@ internal class RemoteConfigFetchCoordinatorTest { // 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(2, TimeUnit.SECONDS)) + assertTrue(transitionEntered.await(30, TimeUnit.SECONDS)) assertFalse(transitionFinished.await(100, TimeUnit.MILLISECONDS)) releaseParser.countDown() - responseThread.join(2_000) - transitionThread.join(2_000) + responseThread.join(30_000) + transitionThread.join(30_000) assertEquals(listOf("callback", "transition"), events) } From f81cedfa9696377cded67e9145c6d94f1818ae54 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Fri, 7 Aug 2026 16:54:09 +0300 Subject: [PATCH 18/30] feat(remote-config): ack every activation that changes the served release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway needs to know which release is actually serving, so an activation that changes the active release now reports itself out of band: POST v3/remote-config-v2/ack with the project token, the very session the snapshot was read under, and {"release_number", "activated_at"}. The ack is deliberately powerless over the config data path. It cannot block or slow an activation (it is queued on a later worker task, strictly after the completion is handed off), it never calls back into the app, it never feeds the fetch policy, and every failure is silent — the only trace of a lost ack is a counter, not a log line, so a flapping gateway cannot become a log storm. Semantics: - exactly one ack per (scope, release), which survives a restart because the last acked release is persisted next to the pending one; - an implicit (read-triggered) activation is acked exactly like an explicit one, and the explicit activate() that follows reports Unchanged and stays silent; - at most one ack in flight per scope, newest activation wins; - a queued ack is durable and is resumed when its identity is bound again; - retries are bounded to three jittered attempts, then abandoned in-process while the durable record keeps the ack owed; - an identity change fences delivery: one identity's session may never vouch for another identity's activation. The route rides the existing transport seam — same bootstrap, same single re-bootstrap-on-401 rule — which is why mint() now reports a typed outcome instead of writing a fetch response directly. The fetch path's behaviour is unchanged. Claude-Session: https://claude.ai/code/session_018pHXqfbxkMQJFzUZ3jW4A8 --- .../remoteconfig/RemoteConfigActivationAck.kt | 458 ++++++++++++++++++ .../RemoteConfigGatewayTransport.kt | 244 +++++++++- .../remoteconfig/RemoteConfigV2Factory.kt | 15 +- .../remoteconfig/RemoteConfigV2Manager.kt | 61 ++- ...emoteConfigActivationAckIntegrationTest.kt | 173 +++++++ .../RemoteConfigActivationAckTest.kt | 423 ++++++++++++++++ .../remoteconfig/RemoteConfigV2TestHarness.kt | 114 ++++- 7 files changed, 1443 insertions(+), 45 deletions(-) create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigActivationAck.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigActivationAckIntegrationTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigActivationAckTest.kt 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..6717cf9e9 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigActivationAck.kt @@ -0,0 +1,458 @@ +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. + * + * [lastAckedReleaseNumber] 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. + */ +internal data class RemoteConfigActivationAckRecord( + val pending: RemoteConfigActivationAck?, + val lastAckedReleaseNumber: 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.** [maxAttempts] attempts with exponentially growing, jittered delays, + * then the in-process delivery is abandoned. The durable record survives that abandonment, so + * the next process start (or the next identity binding) picks it up once — which is a bounded + * number of attempts per process, never a storm inside 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 dropped = AtomicLong() + private var boundScope: RemoteConfigSnapshotScope? = null + private var pending: RemoteConfigActivationAck? = null + private var lastAckedReleaseNumber = 0L + private var generation = 0L + private var inFlight = false + private var attempt = 0 + private var retryScheduled = false + private var retryTask: RemoteConfigFetchScheduledTask? = null + + /** 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 + lastAckedReleaseNumber = 0 + if (scope != null) { + val record = loadRecord(scope) + pending = record?.pending + lastAckedReleaseNumber = record?.lastAckedReleaseNumber ?: 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 + synchronized(lock) { + if (scope != boundScope) return + if (releaseNumber == lastAckedReleaseNumber) return + if (pending?.releaseNumber == releaseNumber) return + pending = RemoteConfigActivationAck(releaseNumber, nowSeconds()) + persistLocked(scope) + // The newest activation supersedes an older in-flight or scheduled one. + invalidateLocked() + } + 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 + inFlight = true + attempt = 1 + Attempt(generation, scope, ack) + } + dispatch(started) + } + + private fun dispatch(sending: Attempt) { + try { + transport.sendAck(sending.scope, sending.ack) { response -> onResponse(sending, response) } + } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) { + onResponse(sending, RemoteConfigAckResponse.Retryable) + } + } + + private fun onResponse(sent: Attempt, response: RemoteConfigAckResponse) { + if (!sent.claim()) return + var retryDelayMillis: Long? = null + 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 + when (response) { + RemoteConfigAckResponse.Delivered -> { + lastAckedReleaseNumber = maxOf(lastAckedReleaseNumber, sent.ack.releaseNumber) + clearPendingLocked(sent) + } + RemoteConfigAckResponse.Permanent -> { + dropped.incrementAndGet() + clearPendingLocked(sent) + } + // Not an attempt: the retry budget is untouched and the record stays queued. + RemoteConfigAckResponse.NotAddressable -> Unit + RemoteConfigAckResponse.Retryable -> if (attempt >= maxAttempts) { + dropped.incrementAndGet() + } else { + retryDelayMillis = retryDelayLocked(attempt) + } + } + retryDelayMillis?.let { scheduleRetryLocked(it) } + } + } + + private fun clearPendingLocked(sent: Attempt) { + if (pending?.releaseNumber == sent.ack.releaseNumber) pending = null + persistLocked(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 + return (cap.toDouble() * jitter).toLong().coerceAtLeast(MINIMUM_RETRY_DELAY_MILLIS) + } + + private fun persistLocked(scope: RemoteConfigSnapshotScope) { + val record = RemoteConfigActivationAckRecord(pending, lastAckedReleaseNumber) + try { + if (record.pending == null && record.lastAckedReleaseNumber <= 0) { + store.clear(scope) + } else { + store.save(scope, 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 is required to tolerate. + } + } + + private fun loadRecord(scope: RemoteConfigSnapshotScope): RemoteConfigActivationAckRecord? = try { + store.load(scope) + } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) { + null + } + + private fun nowSeconds(): Long = try { + clock.nowMillis().coerceAtLeast(0) / MILLIS_PER_SECOND + } catch (@Suppress("TooGenericExceptionCaught") _: Throwable) { + 0 + } + + /** + * 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) }, + lastAckedReleaseNumber = persisted.lastAckedReleaseNumber, + ) + } + + @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, + lastAckedReleaseNumber = record.lastAckedReleaseNumber, + ) + 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 && + lastAckedReleaseNumber >= 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 = "last_acked_release_number") + val lastAckedReleaseNumber: 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/RemoteConfigGatewayTransport.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransport.kt index a5c5b4384..b0558ce9c 100644 --- 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 @@ -19,6 +19,7 @@ import java.util.concurrent.atomic.AtomicBoolean internal const val REMOTE_CONFIG_SESSION_PATH = "v3/remote-config-v2/session" 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_SESSION_HEADER = "X-Qonversion-RC-Session" internal const val REMOTE_CONFIG_SNAPSHOT_BODY_MAX_BYTES = 8L * 1024 * 1024 @@ -28,8 +29,13 @@ 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 @@ -123,13 +129,18 @@ internal fun interface RemoteConfigTransportIdentityProvider { * 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]. It is a strictly out-of-band signal: it shares the session, the bootstrap and the + * single re-bootstrap-on-401 rule, and nothing else. It can neither admit nor 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") +@Suppress("LongParameterList", "TooManyFunctions") internal class RemoteConfigGatewayTransport( private val callFactory: Call.Factory, private val baseUrlProvider: () -> String, @@ -141,10 +152,11 @@ internal class RemoteConfigGatewayTransport( moshi: Moshi, private val logger: Logger, private val maxSnapshotBodyBytes: Long = REMOTE_CONFIG_SNAPSHOT_BODY_MAX_BYTES, -) : RemoteConfigFetchTransport { +) : RemoteConfigFetchTransport, RemoteConfigAckTransport { private val bootstrapRequestAdapter = moshi.adapter(RemoteConfigSessionRequest::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 lock = Any() private var cachedKey: RemoteConfigSessionKey? = null @@ -166,8 +178,12 @@ internal class RemoteConfigGatewayTransport( 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, deliver) { minted -> - requestSnapshot(identity, context, minted, request, deliver, allowReBootstrap = false) + 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) } @@ -175,6 +191,110 @@ internal class RemoteConfigGatewayTransport( } } + /** + * 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 -> { + forgetSession(identity.sessionKey) + 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) + } + } + /** * Pins the project id this session was minted for, or returns the response that refuses it. * @@ -208,7 +328,7 @@ internal class RemoteConfigGatewayTransport( context: RemoteConfigClientContext, session: RemoteConfigGatewaySession, request: RemoteConfigFetchRequest, - deliver: SingleDelivery, + deliver: SingleDelivery, allowReBootstrap: Boolean, ) { val body = try { @@ -228,7 +348,7 @@ internal class RemoteConfigGatewayTransport( deliver(RemoteConfigFetchResponse.Failure()) return } - enqueue(httpRequest, deliver) { outcome -> + enqueue(httpRequest, onThrow = { deliver(RemoteConfigFetchResponse.Failure()) }) { outcome -> onSnapshotOutcome(identity, context, session, request, deliver, allowReBootstrap, outcome) } } @@ -239,7 +359,7 @@ internal class RemoteConfigGatewayTransport( context: RemoteConfigClientContext, session: RemoteConfigGatewaySession, request: RemoteConfigFetchRequest, - deliver: SingleDelivery, + deliver: SingleDelivery, allowReBootstrap: Boolean, outcome: HttpOutcome?, ) { @@ -257,18 +377,35 @@ internal class RemoteConfigGatewayTransport( deliver(RemoteConfigFetchResponse.Failure(statusCode = outcome.code)) return } - mint(identity, deliver) { session -> - requestSnapshot(identity, context, session, request, deliver, allowReBootstrap = false) + 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, - deliver: SingleDelivery, - onMinted: (RemoteConfigGatewaySession) -> Unit, + onResult: (MintResult) -> Unit, ) { val body = try { bootstrapRequestAdapter.toJson(RemoteConfigSessionRequest(identity.userUid)) @@ -277,10 +414,15 @@ internal class RemoteConfigGatewayTransport( } val httpRequest = body?.let { buildRequest(REMOTE_CONFIG_SESSION_PATH, identity, it) } if (httpRequest == null) { - deliver(RemoteConfigFetchResponse.Failure()) + onResult(MintResult.refused(RemoteConfigFetchResponse.Failure(), RemoteConfigAckResponse.Permanent)) return } - enqueue(httpRequest, deliver) { outcome -> + enqueue( + httpRequest, + onThrow = { + onResult(MintResult.refused(RemoteConfigFetchResponse.Failure(), RemoteConfigAckResponse.Retryable)) + }, + ) { outcome -> val session = outcome ?.takeIf { it.code == HTTP_OK } ?.body @@ -289,17 +431,22 @@ internal class RemoteConfigGatewayTransport( 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. - deliver(if (outcome?.code == HTTP_OK) RemoteConfigFetchResponse.Failure() else outcome.asFailure()) + 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 -> - deliver(refusal) + onResult(MintResult.refused(refusal, refusal.toAckResponse())) return@enqueue } rememberSession(identity.sessionKey, session) - onMinted(session) + onResult(MintResult.Minted(session)) } } @@ -332,15 +479,19 @@ internal class RemoteConfigGatewayTransport( } /** - * Every exit of this method must end in exactly one [deliver] call: 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. + * 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, deliver: SingleDelivery, onOutcome: (HttpOutcome?) -> Unit) { + private fun enqueue(request: Request, onThrow: () -> Unit, onOutcome: (HttpOutcome?) -> Unit) { fun handle(outcome: HttpOutcome?) = try { onOutcome(outcome) } catch (_: Throwable) { - deliver(RemoteConfigFetchResponse.Failure()) + try { + onThrow() + } catch (_: Throwable) { + // The caller's own last-resort completion is best effort by definition. + } } val call = try { @@ -477,16 +628,32 @@ internal class RemoteConfigGatewayTransport( deviceInstalledAt = deviceInstalledAtSeconds, ) - private class SingleDelivery( - private val completion: (RemoteConfigFetchResponse) -> Unit, - ) : (RemoteConfigFetchResponse) -> Unit { + private class SingleDelivery( + private val completion: (T) -> Unit, + ) : (T) -> Unit { private val delivered = AtomicBoolean(false) - override fun invoke(response: RemoteConfigFetchResponse) { + 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?, @@ -514,6 +681,27 @@ internal class RemoteConfigGatewayTransport( 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 + } } } @@ -582,6 +770,12 @@ internal data class RemoteConfigSessionResponse( @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 RemoteConfigSnapshotRequest( @Json(name = "client_context") val clientContext: RemoteConfigClientContextWire, 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 index a23a11d44..5b67aaf87 100644 --- 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 @@ -94,12 +94,16 @@ internal object RemoteConfigV2Factory { ) val scopeHolder = RemoteConfigV2ScopeHolder() val clock = RemoteConfigFetchClock { System.currentTimeMillis() } + val random = RemoteConfigFetchRandom { Random.Default.nextDouble() } + // One transport for both routes: the activation ack rides 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 coordinator = RemoteConfigFetchCoordinator( core = core, - transport = transport(application, internalConfig, config, scopeHolder, cache, moshi, logger, clock), + transport = transport, policyStore = PersistentRemoteConfigFetchPolicyStore(cache, moshi), clock = clock, - random = { Random.Default.nextDouble() }, + random = random, scheduler = scheduler, policy = RemoteConfigFetchPolicy( minimumFetchIntervalMillis = REMOTE_CONFIG_V2_MINIMUM_FETCH_INTERVAL_MILLIS, @@ -112,6 +116,13 @@ internal object RemoteConfigV2Factory { core = core, readGuard = readGuard, coordinator = coordinator, + ackSender = RemoteConfigActivationAckSender( + transport = transport, + store = PersistentRemoteConfigActivationAckStore(cache, moshi), + clock = clock, + random = random, + scheduler = scheduler, + ), options = RemoteConfigV2Options( projectKey = primaryConfig.projectKey, environmentUid = config.environmentUid, 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 index f79fdc559..98791d9b0 100644 --- 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 @@ -78,6 +78,7 @@ internal class RemoteConfigV2Manager( private val core: RemoteConfigSnapshotCore, private val readGuard: RemoteConfigReadGuard, private val coordinator: RemoteConfigFetchCoordinator, + private val ackSender: RemoteConfigActivationAckSender, private val options: RemoteConfigV2Options, private val scopeHolder: RemoteConfigV2ScopeHolder, private val scheduler: RemoteConfigFetchScheduler, @@ -86,6 +87,23 @@ internal class RemoteConfigV2Manager( 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. * @@ -102,6 +120,10 @@ internal class RemoteConfigV2Manager( scopeHolder.scope = scope 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) if (scope != null) forceFetch(forceReason) } if (!submitted) logger.debug("Remote Config v2 could not apply an identity change") @@ -130,7 +152,13 @@ internal class RemoteConfigV2Manager( } } - val current: QRemoteConfigSnapshot get() = QRemoteConfigSnapshot(readGuard.currentSnapshot()) + 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. @@ -174,13 +202,25 @@ internal class RemoteConfigV2Manager( 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(core.currentSnapshot()), + 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( @@ -206,6 +246,23 @@ internal class RemoteConfigV2Manager( 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 + submit { ackSender.recordActivation(scope, releaseNumber) } + } + private fun scopeFor(canonicalUserId: String): RemoteConfigSnapshotScope? = try { RemoteConfigSnapshotScope( projectKey = options.projectKey, 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..8a26ad7ce --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigActivationAckTest.kt @@ -0,0 +1,423 @@ +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?.lastAckedReleaseNumber == 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?.lastAckedReleaseNumber == 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?.lastAckedReleaseNumber == 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) + awaitRecord { it?.pending == null } + scheduler.runAll() + assertEquals(2, gateway.acks.size) + assertEquals(0, store.load(SCOPE_A)?.lastAckedReleaseNumber ?: 0) + } + + @Test + fun `a 404 is permanent and is never retried`() { + gateway.scriptAck(HTTP_NOT_FOUND) + val sender = sender() + sender.bind(SCOPE_A) + + sender.recordActivation(SCOPE_A, RELEASE_7) + awaitAcks(1) + + awaitDropped(sender, 1) + scheduler.runAll() + assertEquals(1, gateway.acks.size) + assertNull(store.load(SCOPE_A)?.pending) + } + + @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) + awaitAcks(1) + scheduler.runAll() + awaitAcks(2) + scheduler.runAll() + awaitAcks(3) + awaitDropped(sender, 1) + + // Three attempts, jittered off an exponential cap, and nothing scheduled afterwards. + assertEquals(REMOTE_CONFIG_ACK_MAX_ATTEMPTS, gateway.acks.size) + assertEquals(listOf(500L, 1_000L), 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 `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?.lastAckedReleaseNumber == 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) + awaitAcks(1) + scheduler.runAll() + awaitAcks(2) + scheduler.runAll() + awaitAcks(3) + awaitDropped(crashed, 1) + + // 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.lastAckedReleaseNumber == 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?.lastAckedReleaseNumber == 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(), + ) + + 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 + 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/RemoteConfigV2TestHarness.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt index 1062d2155..ec4ef5da5 100644 --- 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 @@ -38,10 +38,22 @@ 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_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) @@ -118,6 +130,10 @@ internal class RemoteConfigV2Harness( 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(), clientContextProvider: RemoteConfigClientContextProvider = RemoteConfigClientContextProvider { RemoteConfigClientContext( platform = "android", @@ -133,12 +149,12 @@ internal class RemoteConfigV2Harness( private val bundledEntries = bundled?.release private val httpClient = OkHttpClient() val server = MockWebServer() - val snapshotStore = InMemorySnapshotStore() 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()) @Volatile var userUid: String = "QON_anon_a" @@ -147,6 +163,8 @@ internal class RemoteConfigV2Harness( 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 hangAcks = AtomicReference(false) private val contextFingerprint = AtomicReference(RC_FINGERPRINT) private val worker: ExecutorService = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "qonversion-test-worker") @@ -167,23 +185,35 @@ internal class RemoteConfigV2Harness( telemetry = { event -> guardEvents += event }, ) + 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 coordinator = RemoteConfigFetchCoordinator( core = core, - 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(), - ), + transport = transport, policyStore = InMemoryFetchPolicyStore(), clock = { System.currentTimeMillis() }, random = { 0.5 }, @@ -200,6 +230,7 @@ internal class RemoteConfigV2Harness( core = core, readGuard = readGuard, coordinator = coordinator, + ackSender = ackSender, options = RemoteConfigV2Options(RC_PROJECT_KEY, RC_ENVIRONMENT), scopeHolder = scopeHolder, scheduler = timeoutScheduler, @@ -226,6 +257,18 @@ internal class RemoteConfigV2Harness( 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()) + } + } else -> MockResponse().setResponseCode(404) } } @@ -248,6 +291,22 @@ internal class RemoteConfigV2Harness( /** 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) + + /** 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. @@ -443,6 +502,29 @@ internal class InMemorySessionStore : RemoteConfigSessionStore { } } +/** + * 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 + } +} + internal class InMemoryProjectIdStore : RemoteConfigProjectIdStore { private val projectIds = mutableMapOf, Long>() From 47151e10547682e1fdbac4f301d04e0f9202c346 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Fri, 7 Aug 2026 17:04:56 +0300 Subject: [PATCH 19/30] review: keep the activation ack from ever becoming a storm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from an adversarial pass over the ack queue, in severity order: - A permanently refused ack was forgotten instead of settled, so its durable record was deleted and the very same ack was re-queued and re-POSTed on every process start and every identity binding. The likeliest permanent refusal is a 404 from a gateway that does not serve /ack yet, i.e. exactly the rollout state — so the bug was the storm the design forbids. A release is now settled by either answer, delivered or permanently refused. - The three-attempt bound was per binding, not per process: once the ladder was exhausted any bind() bought another three. Abandonment is now remembered in memory for the process, and only a newer release re-arms delivery. - The durable write ran while holding the sender lock, so a synchronous SharedPreferences commit on an OkHttp thread could park the Remote Config worker — the thread that runs activate() and the fetch. Records are now prepared under the lock and written outside it, ordered by a write stamp. - Ack retries ran their preferences read and durable write on the shared timer thread, which also releases fetch waiters; they now only wake it and do the work on the Remote Config worker. - The ack route cleared the shared gateway session on a 401. An out-of-band signal may not invalidate state the config read path depends on: it now just mints a replacement. - Full-downward jitter could put all three attempts inside milliseconds; the delay is now half the cap plus jitter. - A rejected worker submit left the activation marked as handed off, losing that ack for good, and a zero activation timestamp made the queued ack silently un-persistable. Tests follow the corrected semantics and no longer race the retry timer they arm: the 503 ladder waits for the scheduled retry instead of assuming it is already there. Claude-Session: https://claude.ai/code/session_018pHXqfbxkMQJFzUZ3jW4A8 --- .../remoteconfig/RemoteConfigActivationAck.kt | 148 +++++++++++++----- .../RemoteConfigGatewayTransport.kt | 4 +- .../remoteconfig/RemoteConfigV2Factory.kt | 42 ++++- .../remoteconfig/RemoteConfigV2Manager.kt | 6 +- .../RemoteConfigActivationAckTest.kt | 80 +++++++--- 5 files changed, 205 insertions(+), 75 deletions(-) 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 index 6717cf9e9..fd5342cc1 100644 --- 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 @@ -35,13 +35,14 @@ internal data class RemoteConfigActivationAck( /** * The durable ack bookkeeping of one identity scope. * - * [lastAckedReleaseNumber] 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. + * [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 lastAckedReleaseNumber: Long, + val settledReleaseNumber: Long, ) internal interface RemoteConfigActivationAckStore { @@ -89,10 +90,11 @@ internal fun interface RemoteConfigAckTransport { * 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.** [maxAttempts] attempts with exponentially growing, jittered delays, - * then the in-process delivery is abandoned. The durable record survives that abandonment, so - * the next process start (or the next identity binding) picks it up once — which is a bounded - * number of attempts per process, never a storm inside one. + * 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. @@ -109,15 +111,29 @@ internal class RemoteConfigActivationAckSender( 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 lastAckedReleaseNumber = 0L + 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() @@ -136,11 +152,11 @@ internal class RemoteConfigActivationAckSender( invalidateLocked() boundScope = scope pending = null - lastAckedReleaseNumber = 0 + settledReleaseNumber = 0 if (scope != null) { val record = loadRecord(scope) pending = record?.pending - lastAckedReleaseNumber = record?.lastAckedReleaseNumber ?: 0 + settledReleaseNumber = record?.settledReleaseNumber ?: 0 } } startIfIdle() @@ -157,15 +173,18 @@ internal class RemoteConfigActivationAckSender( @Suppress("ReturnCount") fun recordActivation(scope: RemoteConfigSnapshotScope?, releaseNumber: Long) { if (scope == null || releaseNumber <= 0) return - synchronized(lock) { + val write = synchronized(lock) { if (scope != boundScope) return - if (releaseNumber == lastAckedReleaseNumber) return + if (releaseNumber == settledReleaseNumber) return if (pending?.releaseNumber == releaseNumber) return pending = RemoteConfigActivationAck(releaseNumber, nowSeconds()) - persistLocked(scope) // 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() } @@ -187,6 +206,7 @@ internal class RemoteConfigActivationAckSender( 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) @@ -194,6 +214,11 @@ internal class RemoteConfigActivationAckSender( 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) } @@ -202,38 +227,49 @@ internal class RemoteConfigActivationAckSender( } } + @Suppress("ReturnCount") private fun onResponse(sent: Attempt, response: RemoteConfigAckResponse) { if (!sent.claim()) return var retryDelayMillis: Long? = null - synchronized(lock) { + 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 - when (response) { - RemoteConfigAckResponse.Delivered -> { - lastAckedReleaseNumber = maxOf(lastAckedReleaseNumber, sent.ack.releaseNumber) - clearPendingLocked(sent) - } + 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() - clearPendingLocked(sent) + settleLocked(sent) } // Not an attempt: the retry budget is untouched and the record stays queued. - RemoteConfigAckResponse.NotAddressable -> Unit + 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 clearPendingLocked(sent: Attempt) { + private fun settleLocked(sent: Attempt): PendingWrite { + settledReleaseNumber = maxOf(settledReleaseNumber, sent.ack.releaseNumber) if (pending?.releaseNumber == sent.ack.releaseNumber) pending = null - persistLocked(sent.scope) + return prepareWriteLocked(sent.scope) } private fun scheduleRetryLocked(delayMillis: Long) { @@ -298,34 +334,64 @@ internal class RemoteConfigActivationAckSender( SAFE_FALLBACK_JITTER } val jitter = randomValue.takeIf { it.isFinite() && it >= 0.0 && it < 1.0 } ?: SAFE_FALLBACK_JITTER - return (cap.toDouble() * jitter).toLong().coerceAtLeast(MINIMUM_RETRY_DELAY_MILLIS) + // 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 persistLocked(scope: RemoteConfigSnapshotScope) { - val record = RemoteConfigActivationAckRecord(pending, lastAckedReleaseNumber) - try { - if (record.pending == null && record.lastAckedReleaseNumber <= 0) { - store.clear(scope) - } else { - store.save(scope, record) + 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. } - } 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 is required to tolerate. } } + 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. @@ -379,7 +445,7 @@ internal class PersistentRemoteConfigActivationAckStore( pending = persisted.pendingReleaseNumber .takeIf { it > 0 } ?.let { RemoteConfigActivationAck(it, persisted.pendingActivatedAtSeconds) }, - lastAckedReleaseNumber = persisted.lastAckedReleaseNumber, + settledReleaseNumber = persisted.settledReleaseNumber, ) } @@ -390,7 +456,7 @@ internal class PersistentRemoteConfigActivationAckStore( version = REMOTE_CONFIG_ACK_VERSION, pendingReleaseNumber = record.pending?.releaseNumber ?: 0, pendingActivatedAtSeconds = record.pending?.activatedAtSeconds ?: 0, - lastAckedReleaseNumber = record.lastAckedReleaseNumber, + settledReleaseNumber = record.settledReleaseNumber, ) if (!persisted.isValid()) return false val raw = try { @@ -419,7 +485,7 @@ internal class PersistentRemoteConfigActivationAckStore( private fun PersistedRemoteConfigActivationAck.isValid(): Boolean = version == REMOTE_CONFIG_ACK_VERSION && pendingReleaseNumber >= 0 && - lastAckedReleaseNumber >= 0 && + settledReleaseNumber >= 0 && pendingActivatedAtSeconds >= 0 && (pendingReleaseNumber == 0L || pendingActivatedAtSeconds > 0) @@ -439,8 +505,8 @@ internal data class PersistedRemoteConfigActivationAck( val pendingReleaseNumber: Long, @Json(name = "pending_activated_at") val pendingActivatedAtSeconds: Long, - @Json(name = "last_acked_release_number") - val lastAckedReleaseNumber: Long, + @Json(name = "settled_release_number") + val settledReleaseNumber: Long, ) private fun remoteConfigAckStorageKey(scope: RemoteConfigSnapshotScope): String { 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 index b0558ce9c..aa0fe8442 100644 --- 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 @@ -277,7 +277,9 @@ internal class RemoteConfigGatewayTransport( outcome == null -> deliver(RemoteConfigAckResponse.Retryable) outcome.code in HTTP_SUCCESS_MIN..HTTP_SUCCESS_MAX -> deliver(RemoteConfigAckResponse.Delivered) outcome.code == HTTP_UNAUTHORIZED -> { - forgetSession(identity.sessionKey) + // 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 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 index 5b67aaf87..306ac2d1d 100644 --- 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 @@ -15,6 +15,7 @@ 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 @@ -116,13 +117,7 @@ internal object RemoteConfigV2Factory { core = core, readGuard = readGuard, coordinator = coordinator, - ackSender = RemoteConfigActivationAckSender( - transport = transport, - store = PersistentRemoteConfigActivationAckStore(cache, moshi), - clock = clock, - random = random, - scheduler = scheduler, - ), + ackSender = ackSender(transport, cache, moshi, clock, random, scheduler, worker), options = RemoteConfigV2Options( projectKey = primaryConfig.projectKey, environmentUid = config.environmentUid, @@ -135,6 +130,39 @@ internal object RemoteConfigV2Factory { ) } + /** + * 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. + } + } + }, + ) + @Suppress("LongParameterList") private fun transport( application: Application, 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 index 98791d9b0..d061e6b86 100644 --- 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 @@ -260,7 +260,11 @@ internal class RemoteConfigV2Manager( val scope = scopeHolder.scope ?: return val noted = NotedActivation(scope, releaseNumber) if (notedActivation.getAndSet(noted) == noted) return - submit { ackSender.recordActivation(scope, releaseNumber) } + // 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 { 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 index 8a26ad7ce..3b0851614 100644 --- 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 @@ -81,7 +81,7 @@ internal class RemoteConfigActivationAckTest { 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?.lastAckedReleaseNumber == RELEASE_7 } + awaitRecord { it?.pending == null && it?.settledReleaseNumber == RELEASE_7 } assertEquals(0, sender.droppedAckCount) } @@ -91,7 +91,7 @@ internal class RemoteConfigActivationAckTest { sender.bind(SCOPE_A) sender.recordActivation(SCOPE_A, RELEASE_7) awaitAcks(1) - awaitRecord { it?.lastAckedReleaseNumber == RELEASE_7 } + awaitRecord { it?.settledReleaseNumber == RELEASE_7 } // The same release re-reported (an implicit activation followed by an explicit activate()). sender.recordActivation(SCOPE_A, RELEASE_7) @@ -111,7 +111,7 @@ internal class RemoteConfigActivationAckTest { sender.recordActivation(SCOPE_A, RELEASE_7) awaitAcks(2) - awaitRecord { it?.lastAckedReleaseNumber == RELEASE_7 && it.pending == null } + 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 }) @@ -131,25 +131,32 @@ internal class RemoteConfigActivationAckTest { awaitAcks(2) awaitDropped(sender, 1) - awaitRecord { it?.pending == null } + // 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) - assertEquals(0, store.load(SCOPE_A)?.lastAckedReleaseNumber ?: 0) } @Test - fun `a 404 is permanent and is never retried`() { + 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) - assertNull(store.load(SCOPE_A)?.pending) } @Test @@ -159,16 +166,11 @@ internal class RemoteConfigActivationAckTest { sender.bind(SCOPE_A) sender.recordActivation(SCOPE_A, RELEASE_7) - awaitAcks(1) - scheduler.runAll() - awaitAcks(2) - scheduler.runAll() - awaitAcks(3) - awaitDropped(sender, 1) + runRetryLadder(sender) - // Three attempts, jittered off an exponential cap, and nothing scheduled afterwards. + // Three attempts, half-cap plus jitter, and nothing scheduled afterwards. assertEquals(REMOTE_CONFIG_ACK_MAX_ATTEMPTS, gateway.acks.size) - assertEquals(listOf(500L, 1_000L), scheduler.requestedDelays) + assertEquals(listOf(750L, 1_500L), scheduler.requestedDelays) assertEquals(0, scheduler.pendingCount()) scheduler.runAll() assertEquals(REMOTE_CONFIG_ACK_MAX_ATTEMPTS, gateway.acks.size) @@ -176,6 +178,27 @@ internal class RemoteConfigActivationAckTest { 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) @@ -192,7 +215,7 @@ internal class RemoteConfigActivationAckTest { "{\"release_number\":$RELEASE_9,\"activated_at\":$LATER_ACTIVATED_AT_SECONDS}", gateway.acks[1].body, ) - awaitRecord { it?.lastAckedReleaseNumber == RELEASE_9 && it.pending == null } + 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) @@ -205,12 +228,7 @@ internal class RemoteConfigActivationAckTest { val crashed = sender() crashed.bind(SCOPE_A) crashed.recordActivation(SCOPE_A, RELEASE_7) - awaitAcks(1) - scheduler.runAll() - awaitAcks(2) - scheduler.runAll() - awaitAcks(3) - awaitDropped(crashed, 1) + runRetryLadder(crashed) // A new process: new sender, new scheduler, same durable store. scheduler = ManualScheduler() @@ -224,7 +242,7 @@ internal class RemoteConfigActivationAckTest { "{\"release_number\":$RELEASE_7,\"activated_at\":$ACTIVATED_AT_SECONDS}", gateway.acks[3].body, ) - awaitRecord { it != null && it.pending == null && it.lastAckedReleaseNumber == RELEASE_7 } + awaitRecord { it != null && it.pending == null && it.settledReleaseNumber == RELEASE_7 } assertEquals(0, restarted.droppedAckCount) } @@ -244,7 +262,7 @@ internal class RemoteConfigActivationAckTest { assertEquals(1, scheduler.pendingCount()) scheduler.runAll() awaitAcks(2) - awaitRecord { it?.lastAckedReleaseNumber == RELEASE_7 } + awaitRecord { it?.settledReleaseNumber == RELEASE_7 } assertEquals(2, gateway.acks.size) } @@ -328,6 +346,17 @@ internal class RemoteConfigActivationAckTest { 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 } @@ -416,6 +445,7 @@ internal class RemoteConfigActivationAckTest { 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") From 2ef11f39a34bfc66d72645140e5801085e70dbab Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Fri, 7 Aug 2026 17:16:42 +0300 Subject: [PATCH 20/30] test: stop pinning an interleaving the fetch coordinator never promised MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `identity transition waits for admitted response and its callback delivery boundary` asserted that the fetch callback always runs before transitionTo() returns. 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 the waiter is then told Superseded rather than Fetched, because its identity is already gone. Both interleavings are correct. The window is small enough that the test passed in isolation and failed in roughly two of five full-suite runs once the suite grew. What the test actually guards is kept and stated: a transition may not run while a response is being admitted, the waiter is answered exactly once, and both halves complete. The ordering assertion is replaced by an assertion on the result, which is the thing the app can observe. Claude-Session: https://claude.ai/code/session_018pHXqfbxkMQJFzUZ3jW4A8 --- .../RemoteConfigFetchCoordinatorTest.kt | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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 index 0263f1dad..507db6ed7 100644 --- 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 @@ -380,7 +380,11 @@ internal class RemoteConfigFetchCoordinatorTest { val coordinator = coordinator(transport = transport, core = core) coordinator.transitionTo(scope) val events = Collections.synchronizedList(mutableListOf()) - coordinator.fetch { events += "callback" } + val results = Collections.synchronizedList(mutableListOf()) + coordinator.fetch { result -> + events += "callback" + results += result + } val responseThread = Thread { transport.complete(success("admitted", 1)) } responseThread.start() @@ -399,11 +403,26 @@ internal class RemoteConfigFetchCoordinatorTest { // "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) - assertEquals(listOf("callback", "transition"), events) + + // 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 From e5c2218c277009d9b7c520ffbc6705f0b5c0de9f Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Mon, 10 Aug 2026 20:31:10 +0300 Subject: [PATCH 21/30] feat(remote-config): report decode failures and read-guard events to the gateway Telemetry events coalesce by (kind, logical key) with the newest release number, persist durably per identity scope, and flush in bounded batches over the existing gateway session without ever minting one. Stale or future-skewed events are pruned before dispatch, a permanently refused identity is poisoned for the process lifetime, and nothing on this path can touch the config data path or block a read. --- .../RemoteConfigGatewayTransport.kt | 142 +- .../remoteconfig/RemoteConfigSnapshot.kt | 29 + .../remoteconfig/RemoteConfigSnapshotCore.kt | 13 +- .../remoteconfig/RemoteConfigTelemetry.kt | 1005 +++++++++++++++ .../remoteconfig/RemoteConfigV2Factory.kt | 106 +- .../remoteconfig/RemoteConfigV2Manager.kt | 20 + ...ersistentRemoteConfigTelemetryStoreTest.kt | 211 +++ .../RemoteConfigDecodeFailureTelemetryTest.kt | 152 +++ ...emoteConfigTelemetryFactoryDormancyTest.kt | 138 ++ .../RemoteConfigTelemetrySenderTest.kt | 1145 +++++++++++++++++ .../RemoteConfigTelemetryTransportTest.kt | 274 ++++ .../remoteconfig/RemoteConfigV2TestHarness.kt | 127 +- 12 files changed, 3329 insertions(+), 33 deletions(-) create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigTelemetry.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigTelemetryStoreTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigDecodeFailureTelemetryTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigTelemetryFactoryDormancyTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigTelemetrySenderTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigTelemetryTransportTest.kt 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 index aa0fe8442..0900c05b0 100644 --- 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 @@ -20,6 +20,7 @@ import java.util.concurrent.atomic.AtomicBoolean internal const val REMOTE_CONFIG_SESSION_PATH = "v3/remote-config-v2/session" 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 @@ -130,8 +131,9 @@ internal fun interface RemoteConfigTransportIdentityProvider { * waiter until its (optional) timeout. * * The same session seam serves the activation ack route — `POST {base}/v3/remote-config-v2/ack` — - * see [sendAck]. It is a strictly out-of-band signal: it shares the session, the bootstrap and the - * single re-bootstrap-on-401 rule, and nothing else. It can neither admit nor invalidate config + * 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 @@ -152,11 +154,12 @@ internal class RemoteConfigGatewayTransport( moshi: Moshi, private val logger: Logger, private val maxSnapshotBodyBytes: Long = REMOTE_CONFIG_SNAPSHOT_BODY_MAX_BYTES, -) : RemoteConfigFetchTransport, RemoteConfigAckTransport { +) : RemoteConfigFetchTransport, RemoteConfigAckTransport, RemoteConfigTelemetryTransport { private val bootstrapRequestAdapter = moshi.adapter(RemoteConfigSessionRequest::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 @@ -297,6 +300,113 @@ internal class RemoteConfigGatewayTransport( } } + /** + * 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. * @@ -620,6 +730,18 @@ internal class RemoteConfigGatewayTransport( 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, @@ -778,6 +900,20 @@ internal data class RemoteConfigActivationAckRequest( @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, 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 index 2bf78b904..13633d753 100644 --- 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 @@ -299,10 +299,22 @@ internal class RemoteConfigResolvedValue( 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 @@ -330,6 +342,11 @@ internal class RemoteConfigSnapshot( 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) @@ -350,6 +367,14 @@ internal class RemoteConfigSnapshot( 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, @@ -372,6 +397,10 @@ internal class RemoteConfigSnapshot( applyPolicy = applyPolicy, metadata = metadataBytes, ) + + private companion object { + val NO_DECODE_FAILURE_OBSERVER = RemoteConfigDecodeFailureObserver { _, _ -> } + } } internal class RemoteConfigSnapshotUpdate( 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 index e56d212d9..d47c6009d 100644 --- 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 @@ -96,6 +96,12 @@ internal class RemoteConfigSnapshotCore( 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() @@ -588,7 +594,12 @@ internal class RemoteConfigSnapshotCore( private fun snapshotFor( primary: RemoteConfigSnapshotRelease?, previous: RemoteConfigSnapshotRelease?, - ) = RemoteConfigSnapshot(primary, previous, bundledRelease?.releaseFor(currentScope)) + ) = RemoteConfigSnapshot( + primaryRelease = primary, + previousRelease = previous, + bundledRelease = bundledRelease?.releaseFor(currentScope), + decodeFailureObserver = decodeFailureObserver, + ) private fun buildUpdate( oldSnapshot: RemoteConfigSnapshot?, 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 index 306ac2d1d..28dbe8492 100644 --- 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 @@ -71,34 +71,27 @@ internal object RemoteConfigV2Factory { val moshi = Moshi.Builder().build() val primaryConfig = internalConfig.primaryConfig val store = PersistentRemoteConfigSnapshotStore(cache, moshi) - val core = RemoteConfigSnapshotCore(store, bundledRelease(application, primaryConfig.projectKey)) // 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 readGuard = 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") }, - ) val scopeHolder = RemoteConfigV2ScopeHolder() val clock = RemoteConfigFetchClock { System.currentTimeMillis() } val random = RemoteConfigFetchRandom { Random.Default.nextDouble() } - // One transport for both routes: the activation ack rides the very same session, bootstrap - // and re-bootstrap-once rule as a snapshot read. + // 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, @@ -112,12 +105,17 @@ internal object RemoteConfigV2Factory { // 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, @@ -130,6 +128,40 @@ internal object RemoteConfigV2Factory { ) } + /** + * 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. * @@ -163,6 +195,42 @@ internal object RemoteConfigV2Factory { }, ) + /** + * 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, 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 index d061e6b86..7b5385545 100644 --- 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 @@ -79,6 +79,7 @@ internal class RemoteConfigV2Manager( 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, @@ -124,6 +125,9 @@ internal class RemoteConfigV2Manager( // 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") @@ -178,6 +182,10 @@ internal class RemoteConfigV2Manager( coordinator.fetch(forceReason) { result -> timeoutTask.cancelSafely() delivery.deliver(result.toPublicResult()) + // 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) { @@ -306,6 +314,18 @@ internal class RemoteConfigV2Manager( 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.toPublicResult(): QRemoteConfigFetchResult = when (this) { is RemoteConfigFetchResult.Fetched -> result(transition.toFetchStatus()) RemoteConfigFetchResult.NotModified -> result(QRemoteConfigFetchStatus.NotModified) 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/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/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/RemoteConfigV2TestHarness.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt index ec4ef5da5..c11e33712 100644 --- 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 @@ -28,6 +28,7 @@ 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" @@ -39,6 +40,7 @@ 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 @@ -134,6 +136,7 @@ internal class RemoteConfigV2Harness( // 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", @@ -155,6 +158,7 @@ internal class RemoteConfigV2Harness( 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" @@ -164,6 +168,7 @@ internal class RemoteConfigV2Harness( 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 -> @@ -175,16 +180,6 @@ internal class RemoteConfigV2Harness( private val scopeHolder = RemoteConfigV2ScopeHolder() private val mainDispatcher = RemoteConfigMainDispatcher { action -> mainExecutor.execute(action) } - val core = RemoteConfigSnapshotCore(snapshotStore, bundled) - - private val readGuard = RemoteConfigReadGuard( - core = core, - preloader = PersistentRemoteConfigReadPreloader(snapshotStore, worker), - buildMode = buildMode, - assertion = { message -> assertions += message }, - telemetry = { event -> guardEvents += event }, - ) - val transport = RemoteConfigGatewayTransport( callFactory = httpClient, baseUrlProvider = { server.url("/").toString() }, @@ -211,6 +206,34 @@ internal class RemoteConfigV2Harness( 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, @@ -224,6 +247,7 @@ internal class RemoteConfigV2Harness( minimumFetchIntervalMillis = minimumFetchIntervalMillis, timeoutMillis = null, ), + policyPersistenceFailureObserver = { telemetrySender.recordPolicyPersistenceFailure() }, ) val manager = RemoteConfigV2Manager( @@ -231,6 +255,7 @@ internal class RemoteConfigV2Harness( readGuard = readGuard, coordinator = coordinator, ackSender = ackSender, + telemetrySender = telemetrySender, options = RemoteConfigV2Options(RC_PROJECT_KEY, RC_ENVIRONMENT), scopeHolder = scopeHolder, scheduler = timeoutScheduler, @@ -269,6 +294,14 @@ internal class RemoteConfigV2Harness( 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) } } @@ -297,6 +330,19 @@ internal class RemoteConfigV2Harness( /** 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) @@ -525,6 +571,67 @@ internal class InMemoryActivationAckStore : RemoteConfigActivationAckStore { } } +/** + * 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>() From fc7c64cda25450d7c472fd18f098bcdd3067addd Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Tue, 11 Aug 2026 13:18:58 +0300 Subject: [PATCH 22/30] feat(remote-config): make the fetch floor configurable and unthrottle debug builds Addendum B: the minimum interval between real network fetches is now the app's to set via QRemoteConfigV2Config.minFetchIntervalSeconds. Zero (the default) means auto: the production 60s floor in a release build, no floor at all in a debuggable one. An explicit positive value wins in both build modes; forced fetches still bypass the floor and backoff still applies at interval zero. --- .../dto/remoteconfig/QRemoteConfigV2Config.kt | 11 +- .../remoteconfig/RemoteConfigV2Factory.kt | 17 ++- .../remoteconfig/QRemoteConfigV2ConfigTest.kt | 15 ++- .../RemoteConfigV2FetchIntervalTest.kt | 124 ++++++++++++++++++ 4 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2FetchIntervalTest.kt 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 index cb66d29a3..11b34b63f 100644 --- 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 @@ -34,12 +34,18 @@ private const val REMOTE_CONFIG_V2_UID_MAX_CODE_POINTS = 36 * @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. * @throws IllegalArgumentException if any value is malformed. */ @ExperimentalQonversionApi -class QRemoteConfigV2Config( +class QRemoteConfigV2Config @JvmOverloads constructor( val baseUrl: String, val environmentUid: String, + val minFetchIntervalSeconds: Long = 0, ) { init { require(baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { @@ -49,5 +55,8 @@ class QRemoteConfigV2Config( 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/internal/remoteconfig/RemoteConfigV2Factory.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt index 28dbe8492..75cb4fa4d 100644 --- 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 @@ -100,7 +100,7 @@ internal object RemoteConfigV2Factory { random = random, scheduler = scheduler, policy = RemoteConfigFetchPolicy( - minimumFetchIntervalMillis = REMOTE_CONFIG_V2_MINIMUM_FETCH_INTERVAL_MILLIS, + 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, @@ -128,6 +128,21 @@ internal object RemoteConfigV2Factory { ) } + /** + * 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. * 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 index bc215250d..cb77e54b2 100644 --- 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 @@ -23,6 +23,15 @@ internal class QRemoteConfigV2ConfigTest { 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) + } + + @Test + fun `an explicit minimum fetch interval is accepted verbatim`() { + assertEquals(300, config(minFetchIntervalSeconds = 300).minFetchIntervalSeconds) + assertEquals(0, config(minFetchIntervalSeconds = 0).minFetchIntervalSeconds) } @Test @@ -32,6 +41,7 @@ internal class QRemoteConfigV2ConfigTest { "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) -> @@ -48,7 +58,7 @@ internal class QRemoteConfigV2ConfigTest { QRemoteConfigV2Config::class.java.declaredMethods.map { it.name } members.forEach { name -> assertFalse(name, name.contains("rojectId")) } assertEquals( - setOf("baseUrl", "environmentUid"), + setOf("baseUrl", "environmentUid", "minFetchIntervalSeconds"), QRemoteConfigV2Config::class.java.declaredFields.map { it.name }.toSet(), ) } @@ -56,5 +66,6 @@ internal class QRemoteConfigV2ConfigTest { private fun config( baseUrl: String = "https://gateway.example.com/", environmentUid: String = "production", - ) = QRemoteConfigV2Config(baseUrl, environmentUid) + minFetchIntervalSeconds: Long = 0, + ) = QRemoteConfigV2Config(baseUrl, environmentUid, minFetchIntervalSeconds) } 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 + } +} From d646d3fb7b7e17e7d41ea14926256d4deb99322d Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Tue, 11 Aug 2026 15:32:33 +0300 Subject: [PATCH 23/30] test: pin the RC v2 init wiring, the DI cache binding and the parsing-error mapping QonversionInternalRemoteConfigV2WiringTest pins the init-path contract the release audit flagged as untested: an unconfigured init leaves the identity bridge unbound (both identity moments no-op, no v2 storage touched), a configured init force-fetches once with ForceReason.Build and routes both bridge moments to the v2 manager reading the live uid, and a throwing v2 manager never propagates into the v1 identity path. AppModuleRemoteConfigCacheTest pins the provideRemoteConfigCache binding: the graph serves the persistent last-known-good implementation, scoped by the module's own InternalConfig and durable across provider recreation, using the exact Moshi the graph provides. ErrorsTest pins the JsonDataException -> ResponseParsingFailed mapping. --- .../android/sdk/internal/ErrorsTest.kt | 45 ++++ ...versionInternalRemoteConfigV2WiringTest.kt | 205 ++++++++++++++++++ .../module/AppModuleRemoteConfigCacheTest.kt | 105 +++++++++ 3 files changed, 355 insertions(+) create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/ErrorsTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/QonversionInternalRemoteConfigV2WiringTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/di/module/AppModuleRemoteConfigCacheTest.kt 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..82182fe6d --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/ErrorsTest.kt @@ -0,0 +1,45 @@ +package com.qonversion.android.sdk.internal + +import com.qonversion.android.sdk.dto.QonversionErrorCode +import com.squareup.moshi.JsonDataException +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 `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/QonversionInternalRemoteConfigV2WiringTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/QonversionInternalRemoteConfigV2WiringTest.kt new file mode 100644 index 000000000..7f8532f44 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/QonversionInternalRemoteConfigV2WiringTest.kt @@ -0,0 +1,205 @@ +@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 + + // 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() } + } + + @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() } 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, + ), + ) +} From d3d53e7d8a7a8bf3719b8387acbb3a43ddb6adb3 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Tue, 11 Aug 2026 15:32:34 +0300 Subject: [PATCH 24/30] docs: draft the release-notes callout for enforced TLS verification Covers the removal of the trust-all TLS paths (core OkHttp client and the NoCodes HttpsURLConnection client): who is affected, the SSL handshake failures they will see, and the networkSecurityConfig debug-overrides fix. Also calls out the persistent last-known-good cache semantics, the stricter remoteConfigList parsing, and the new Qonversion.fallbackRemoteConfigValue API. --- docs/release-notes-draft-rc-v2-train.md | 64 +++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/release-notes-draft-rc-v2-train.md 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. From d2968896ba8103e1a91c0424616a4c58775a4781 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Tue, 11 Aug 2026 16:00:27 +0300 Subject: [PATCH 25/30] fix: classify malformed-JSON responses as parsing failures, not network ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moshi throws JsonDataException only for type mismatches; syntactically malformed JSON throws JsonEncodingException, which extends IOException. The mapper's IOException branch therefore reported garbage bodies as NetworkConnectionFailed, and everything keyed on that code (shouldFireFallback, shouldCalculatePermissionsLocally) treated a permanently broken payload as a retryable transient failure. Verified through the real Retrofit+Moshi chain against MockWebServer: garbage and bad-token bodies reach the mapper as JsonEncodingException, type mismatches as JsonDataException, and a truncated body as a plain EOFException — the latter stays in the network branch on purpose, since a truncated stream can genuinely be a dropped connection. Reclassifying is safe for every NetworkConnectionFailed consumer: JsonEncodingException is never a network condition — the bytes arrived, they just are not JSON — so no genuinely-transient case loses its retry. The new ErrorsMoshiChainTest pins both real-parser paths end to end. --- .../qonversion/android/sdk/internal/errors.kt | 7 +- .../sdk/internal/ErrorsMoshiChainTest.kt | 90 +++++++++++++++++++ .../android/sdk/internal/ErrorsTest.kt | 10 +++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/ErrorsMoshiChainTest.kt 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 b438cf4d0..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 @@ -5,6 +5,7 @@ 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 @@ -40,7 +41,11 @@ internal fun BillingError.toQonversionError(): QonversionError { internal fun Throwable.toQonversionError(): QonversionError { return when (this) { - is JSONException, is JsonDataException -> { + // 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/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 index 82182fe6d..d7e04c869 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/ErrorsTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/ErrorsTest.kt @@ -2,6 +2,7 @@ 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 @@ -29,6 +30,15 @@ internal class ErrorsTest { 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() From fc8389dc3af7143b2df123732f874b680639fd1e Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Tue, 11 Aug 2026 16:00:33 +0300 Subject: [PATCH 26/30] test: pin the producer side of the v2 identity bridge QonversionInternalRemoteConfigV2WiringTest fires the bridge manually, so only the consumer side was pinned: deleting the bridge calls in QRemoteConfigManager (onUserUpdate -> identityScopeChanged, invalidateRemoteConfigsCache -> targetingInvalidated) left every test green while silently breaking v2 scope switching on identify/logout. These tests assert each entry point emits its bridge event exactly once, and that a throwing v2 observer cannot break the v1 identity flow. Mutation-checked: commenting out either bridge call fails the matching test. --- .../sdk/internal/QRemoteConfigManagerTest.kt | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) 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 653aa38d4..901987d6c 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 @@ -2312,6 +2312,58 @@ 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 + manager.identityBridge.onIdentityScopeChanged = { identityScopeChanges++ } + manager.identityBridge.onTargetingInvalidated = { targetingInvalidations++ } + + manager.onUserUpdate() + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(1, identityScopeChanges) + assertEquals(0, targetingInvalidations) + } + + @Test + fun `invalidateRemoteConfigsCache fires targetingInvalidated on the v2 bridge exactly once`() { + var identityScopeChanges = 0 + var targetingInvalidations = 0 + manager.identityBridge.onIdentityScopeChanged = { identityScopeChanges++ } + manager.identityBridge.onTargetingInvalidated = { targetingInvalidations++ } + + manager.invalidateRemoteConfigsCache() + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(1, targetingInvalidations) + 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") From b4693a1acc353fe65e807dad42de65865f069279 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Thu, 13 Aug 2026 03:17:13 +0300 Subject: [PATCH 27/30] sample: Calmly paywall demo driven by one Remote Config v2 key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A realistic product screen for the developer-journey demo: headline, products, badges, CTA tint, optional ticking countdown — all rendered from the paywall_config key with a bundled default, a typed decoder that survives undecodable releases (the screen holds the last activated value and reports source=cache), and a source line showing release, policy and metadata. The first version read snapshots.current in onCreateView, which the SDK's read-before-activate guard turns into an assert crash on debug builds — proven live on the emulator. First paint now renders the bundled default without touching SDK state, and every real render flows through activate()'s callback and the update subscription. --- sample/paywall_config.default.json | 23 + sample/src/main/AndroidManifest.xml | 9 +- .../main/java/io/qonversion/sample/App.java | 15 +- .../io/qonversion/sample/OtherFragment.kt | 8 + .../io/qonversion/sample/PaywallConfig.kt | 179 +++++++ .../qonversion/sample/PaywallDemoFragment.kt | 492 ++++++++++++++++++ .../sample/RemoteConfigV2Adapter.kt | 88 ++++ .../sample/RemoteConfigV2Fragment.kt | 246 +++++++++ .../main/java/io/qonversion/sample/utils.kt | 19 + sample/src/main/res/drawable/ic_refresh.xml | 10 + .../main/res/drawable/paywall_badge_chip.xml | 7 + .../res/drawable/paywall_pending_banner.xml | 6 + .../res/drawable/paywall_product_card.xml | 10 + sample/src/main/res/layout/fragment_other.xml | 28 + .../main/res/layout/fragment_paywall_demo.xml | 171 ++++++ .../res/layout/fragment_remote_config_v2.xml | 304 +++++++++++ .../main/res/layout/item_paywall_product.xml | 57 ++ .../main/res/layout/item_remote_config_v2.xml | 142 +++++ sample/src/main/res/navigation/nav_graph.xml | 12 + sample/src/main/res/values/strings.xml | 58 +++ 20 files changed, 1880 insertions(+), 4 deletions(-) create mode 100644 sample/paywall_config.default.json create mode 100644 sample/src/main/java/io/qonversion/sample/PaywallConfig.kt create mode 100644 sample/src/main/java/io/qonversion/sample/PaywallDemoFragment.kt create mode 100644 sample/src/main/java/io/qonversion/sample/RemoteConfigV2Adapter.kt create mode 100644 sample/src/main/java/io/qonversion/sample/RemoteConfigV2Fragment.kt create mode 100644 sample/src/main/res/drawable/ic_refresh.xml create mode 100644 sample/src/main/res/drawable/paywall_badge_chip.xml create mode 100644 sample/src/main/res/drawable/paywall_pending_banner.xml create mode 100644 sample/src/main/res/drawable/paywall_product_card.xml create mode 100644 sample/src/main/res/layout/fragment_paywall_demo.xml create mode 100644 sample/src/main/res/layout/fragment_remote_config_v2.xml create mode 100644 sample/src/main/res/layout/item_paywall_product.xml create mode 100644 sample/src/main/res/layout/item_remote_config_v2.xml 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/java/io/qonversion/sample/App.java b/sample/src/main/java/io/qonversion/sample/App.java index 44cf7466a..459ea9f3c 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() { @@ -29,6 +33,15 @@ public void onCreate() { QLaunchMode.SubscriptionManagement ).setEnvironment(QEnvironment.Sandbox); + // 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 + )); + NoCodesConfig.Builder noCodesConfigBuilder = new NoCodesConfig.Builder( this, projectKey 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..5665f9307 --- /dev/null +++ b/sample/src/main/java/io/qonversion/sample/PaywallConfig.kt @@ -0,0 +1,179 @@ +@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.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 (e: IllegalArgumentException) { + PAYWALL_FALLBACK_ACCENT_COLOR +} + +/** Whether [PaywallConfig.accentColor] would render as configured. */ +fun PaywallConfig.hasValidAccentColor(): Boolean = try { + Color.parseColor(accentColor) + true +} catch (e: 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 (e: Exception) { + // 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..a07914414 --- /dev/null +++ b/sample/src/main/java/io/qonversion/sample/PaywallDemoFragment.kt @@ -0,0 +1,492 @@ +@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.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) { + val binding = _binding ?: return + + val enabled = config.showCountdown && config.countdownSeconds > 0 + if (!enabled) { + countdownHandler.removeCallbacks(countdownTick) + countdownTotalSeconds = 0 + countdownRemainingSeconds = 0 + binding.countdownContainer.visibility = View.GONE + return + } + + 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() + return + } + + 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 (e: Exception) { + 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..495d70f13 --- /dev/null +++ b/sample/src/main/java/io/qonversion/sample/RemoteConfigV2Adapter.kt @@ -0,0 +1,88 @@ +@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.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 (e: Exception) { + 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..7e73e52ca --- /dev/null +++ b/sample/src/main/java/io/qonversion/sample/RemoteConfigV2Fragment.kt @@ -0,0 +1,246 @@ +@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() + renderSnapshot(snapshots.current) + + 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) + 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 + + // 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, + snapshot.contextKeys.size.toString() + ) + } + + val values = snapshot.contextKeys.sorted().mapNotNull { contextKey -> + snapshot.rawValue(contextKey)?.let { value -> ResolvedEntry(contextKey, value) } + } + + 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 + 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… From 3d21683e8c72fa7dd7566c5c4704d146d095b266 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Fri, 14 Aug 2026 00:25:11 +0300 Subject: [PATCH 28/30] feat(rc-v2): secure identity transitions and activation state --- .../qonversion_remote_config_defaults.json | 1 + .../main/java/io/qonversion/sample/App.java | 28 ++++++-- ...RemoteConfigIdentifyAssertionProvider.java | 45 ++++++++++++ .../sample/RemoteConfigV2Fragment.kt | 29 +++++--- sample/src/main/res/values/strings.xml | 2 +- .../dto/remoteconfig/QRemoteConfigSnapshot.kt | 2 +- .../dto/remoteconfig/QRemoteConfigV2Config.kt | 24 +++++++ .../sdk/internal/QProductCenterManager.kt | 8 +-- .../sdk/internal/QRemoteConfigManager.kt | 8 +-- .../sdk/internal/QonversionInternal.kt | 14 ++-- .../RemoteConfigGatewayTransport.kt | 69 +++++++++++++++++-- .../RemoteConfigIdentityBridge.kt | 12 ++-- .../remoteconfig/RemoteConfigSnapshot.kt | 10 +++ .../remoteconfig/RemoteConfigV2Factory.kt | 2 + .../remoteconfig/RemoteConfigV2Manager.kt | 32 ++++++++- .../remoteconfig/QRemoteConfigV2ConfigTest.kt | 3 +- .../sdk/internal/QRemoteConfigManagerTest.kt | 26 ++++--- ...versionInternalRemoteConfigV2WiringTest.kt | 5 +- .../QRemoteConfigsPublicApiTest.kt | 30 ++++++++ .../RemoteConfigGatewayTransportTest.kt | 41 ++++++++++- 20 files changed, 339 insertions(+), 52 deletions(-) create mode 100644 sample/src/main/assets/qonversion_remote_config_defaults.json create mode 100644 sample/src/main/java/io/qonversion/sample/LocalRemoteConfigIdentifyAssertionProvider.java 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 459ea9f3c..81b93b577 100644 --- a/sample/src/main/java/io/qonversion/sample/App.java +++ b/sample/src/main/java/io/qonversion/sample/App.java @@ -26,12 +26,27 @@ 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 @@ -39,7 +54,10 @@ public void onCreate() { qonversionConfigBuilder.setRemoteConfigV2Config(new QRemoteConfigV2Config( UtilsKt.RC_V2_PLAYGROUND_BASE_URL, UtilsKt.RC_V2_PLAYGROUND_ENVIRONMENT_UID, - 0 + 0, + DEFAULT_PROJECT_KEY.equals(projectKey) + ? new LocalRemoteConfigIdentifyAssertionProvider() + : null )); NoCodesConfig.Builder noCodesConfigBuilder = new NoCodesConfig.Builder( @@ -47,9 +65,9 @@ public void onCreate() { 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/RemoteConfigV2Fragment.kt b/sample/src/main/java/io/qonversion/sample/RemoteConfigV2Fragment.kt index 7e73e52ca..14fca504a 100644 --- a/sample/src/main/java/io/qonversion/sample/RemoteConfigV2Fragment.kt +++ b/sample/src/main/java/io/qonversion/sample/RemoteConfigV2Fragment.kt @@ -57,7 +57,7 @@ class RemoteConfigV2Fragment : Fragment() { setupButtons() renderEnvironment() renderSubscriptionState() - renderSnapshot(snapshots.current) + activate() return binding.root } @@ -114,8 +114,19 @@ class RemoteConfigV2Fragment : Fragment() { 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) - renderSnapshot(result.snapshot) + 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) } } @@ -194,6 +205,12 @@ class RemoteConfigV2Fragment : Fragment() { 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) @@ -202,14 +219,10 @@ class RemoteConfigV2Fragment : Fragment() { R.string.rc_v2_release_format, snapshot.releaseNumber.toString(), snapshot.releaseUid, - snapshot.contextKeys.size.toString() + values.size.toString() ) } - val values = snapshot.contextKeys.sorted().mapNotNull { contextKey -> - snapshot.rawValue(contextKey)?.let { value -> ResolvedEntry(contextKey, value) } - } - if (values.isEmpty()) { binding.emptyStateText.visibility = View.VISIBLE binding.recyclerViewValues.visibility = View.GONE diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index da7b49960..6081b4689 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -212,7 +212,7 @@ Identity: %1$s No release activated yet — tap Fetch and activate Release %1$s · %2$s · %3$s keys - Fetch status: %1$s + Fetch status: %1$s · fetched release %2$s Fetch: %1$s · changed: %2$s Update: release %1$s · changed: %2$s nothing 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 index 2b1f4f23c..3dd25afcd 100644 --- 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 @@ -32,7 +32,7 @@ class QRemoteConfigSnapshot internal constructor( val releaseNumber: Long get() = snapshot.releaseNumber /** Every context key readable from this snapshot, including keys served by bundled defaults. */ - val contextKeys: Set get() = snapshot.allKeys + val contextKeys: Set get() = snapshot.readableKeys /** * Reads [contextKey] as the exact JSON text stored for it. 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 index 11b34b63f..478e925fb 100644 --- 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 @@ -4,6 +4,26 @@ 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. * @@ -39,6 +59,9 @@ private const val REMOTE_CONFIG_V2_UID_MAX_CODE_POINTS = 36 * 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 @@ -46,6 +69,7 @@ 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://")) { 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 33cca053a..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,11 +237,11 @@ 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 { - remoteConfigManager.onUserUpdate { + remoteConfigManager.onUserUpdate(identityId) { internalConfig.uid = qonversionUid } launchResultCache.clearPermissionsCache() @@ -474,7 +474,7 @@ internal class QProductCenterManager internal constructor( if (isLogoutNeeded) { val userId = userInfoService.obtainUserId() - remoteConfigManager.onUserUpdate { + remoteConfigManager.onUserUpdate(null) { internalConfig.uid = userId } launchResultCache.clearPermissionsCache() @@ -528,7 +528,7 @@ internal class QProductCenterManager internal constructor( ) userInfoService.storeQonversionUserId(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 abcb6f407..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 @@ -162,12 +162,12 @@ 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() { + fun invalidateRemoteConfigsCache(externalUserId: String? = null) { invalidateOnAnyThread {} - identityBridge.targetingInvalidated() + identityBridge.targetingInvalidated(externalUserId) } - fun onUserUpdate(updateIdentity: () -> Unit = {}) { + 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. @@ -175,7 +175,7 @@ internal class QRemoteConfigManager @Inject constructor( invalidationGeneration.incrementAndGet() userGeneration.incrementAndGet() updateIdentity() - identityBridge.identityScopeChanged() + identityBridge.identityScopeChanged(externalUserId) if (Looper.myLooper() == Looper.getMainLooper()) { resetIdentityStateIfNeeded() } else { 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 84d2627b0..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 @@ -132,15 +132,21 @@ internal class QonversionInternal( ) remoteConfigsV2 = remoteConfigsV2Impl remoteConfigsV2Impl.manager?.let { manager -> - manager.updateIdentity(internalConfig.uid, RemoteConfigFetchForceReason.Build) + 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 = { - manager.updateIdentity(internalConfig.uid, RemoteConfigFetchForceReason.Identify) + 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 = { manager.refreshTargeting() } + remoteConfigManager.identityBridge.onTargetingInvalidated = { externalUserId -> + manager.refreshTargeting(externalUserId) + } } val lifecycleHandler = AppLifecycleHandler(this) 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 index 0900c05b0..8c83e8fa7 100644 --- 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 @@ -1,5 +1,8 @@ +@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 @@ -18,6 +21,7 @@ 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" @@ -25,6 +29,7 @@ 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 @@ -91,6 +96,7 @@ 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) @@ -151,11 +157,13 @@ internal class RemoteConfigGatewayTransport( 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) @@ -519,12 +527,52 @@ internal class RemoteConfigGatewayTransport( identity: RemoteConfigTransportIdentity, onResult: (MintResult) -> Unit, ) { - val body = try { - bootstrapRequestAdapter.toJson(RemoteConfigSessionRequest(identity.userUid)) + 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) { - null + if (delivered.compareAndSet(false, true)) { + onResult(MintResult.refused(RemoteConfigFetchResponse.Failure(), RemoteConfigAckResponse.Permanent)) + } } - val httpRequest = body?.let { buildRequest(REMOTE_CONFIG_SESSION_PATH, identity, it) } + } + + 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 @@ -562,6 +610,14 @@ internal class RemoteConfigGatewayTransport( } } + 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. @@ -886,6 +942,11 @@ 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?, 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 index 4fec8a918..1379cc2ff 100644 --- 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 @@ -13,22 +13,22 @@ 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: (() -> Unit)? = null + 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: (() -> Unit)? = null + var onTargetingInvalidated: ((externalUserId: String?) -> Unit)? = null - fun identityScopeChanged() = notify(onIdentityScopeChanged) + fun identityScopeChanged(externalUserId: String? = null) = notifyIdentity(onIdentityScopeChanged, externalUserId) - fun targetingInvalidated() = notify(onTargetingInvalidated) + fun targetingInvalidated(externalUserId: String? = null) = notifyIdentity(onTargetingInvalidated, externalUserId) - private fun notify(observer: (() -> Unit)?) { + private fun notifyIdentity(observer: ((String?) -> Unit)?, externalUserId: String?) { try { - observer?.invoke() + 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/RemoteConfigSnapshot.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshot.kt index 13633d753..0e2a0614a 100644 --- 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 @@ -325,6 +325,16 @@ internal class RemoteConfigSnapshot( 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? { 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 index 75cb4fa4d..97dd0499f 100644 --- 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 @@ -274,6 +274,7 @@ internal object RemoteConfigV2Factory { // 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, ) } }, @@ -286,6 +287,7 @@ internal object RemoteConfigV2Factory { // 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, ) 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 index 7b5385545..3cb31117a 100644 --- 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 @@ -40,10 +40,15 @@ internal data class RemoteConfigV2Options( */ 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 { @@ -112,13 +117,18 @@ internal class RemoteConfigV2Manager( * 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) { + 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, @@ -140,8 +150,11 @@ internal class RemoteConfigV2Manager( * 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() { + fun refreshTargeting() = refreshTargeting(null) + + fun refreshTargeting(externalUserId: String?) { if (scopeHolder.scope == null) return + if (externalUserId != null) scopeHolder.externalUserId = externalUserId submit { forceFetch(RemoteConfigFetchForceReason.Identify) } } @@ -182,6 +195,14 @@ internal class RemoteConfigV2Manager( 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. @@ -326,6 +347,13 @@ internal class RemoteConfigV2Manager( 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) 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 index cb77e54b2..3d90cd688 100644 --- 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 @@ -26,6 +26,7 @@ internal class QRemoteConfigV2ConfigTest { // 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 @@ -58,7 +59,7 @@ internal class QRemoteConfigV2ConfigTest { QRemoteConfigV2Config::class.java.declaredMethods.map { it.name } members.forEach { name -> assertFalse(name, name.contains("rojectId")) } assertEquals( - setOf("baseUrl", "environmentUid", "minFetchIntervalSeconds"), + setOf("baseUrl", "environmentUid", "minFetchIntervalSeconds", "identifyAssertionProvider"), QRemoteConfigV2Config::class.java.declaredFields.map { it.name }.toSet(), ) } 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 901987d6c..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 @@ -2323,13 +2323,18 @@ internal class QRemoteConfigManagerTest { fun `onUserUpdate fires identityScopeChanged on the v2 bridge exactly once`() { var identityScopeChanges = 0 var targetingInvalidations = 0 - manager.identityBridge.onIdentityScopeChanged = { identityScopeChanges++ } - manager.identityBridge.onTargetingInvalidated = { targetingInvalidations++ } + var bridgedExternalUserId: String? = null + manager.identityBridge.onIdentityScopeChanged = { externalUserId -> + identityScopeChanges++ + bridgedExternalUserId = externalUserId + } + manager.identityBridge.onTargetingInvalidated = { _ -> targetingInvalidations++ } - manager.onUserUpdate() + manager.onUserUpdate("account-1") shadowOf(Looper.getMainLooper()).idle() assertEquals(1, identityScopeChanges) + assertEquals("account-1", bridgedExternalUserId) assertEquals(0, targetingInvalidations) } @@ -2337,20 +2342,25 @@ internal class QRemoteConfigManagerTest { fun `invalidateRemoteConfigsCache fires targetingInvalidated on the v2 bridge exactly once`() { var identityScopeChanges = 0 var targetingInvalidations = 0 - manager.identityBridge.onIdentityScopeChanged = { identityScopeChanges++ } - manager.identityBridge.onTargetingInvalidated = { targetingInvalidations++ } + var bridgedExternalUserId: String? = null + manager.identityBridge.onIdentityScopeChanged = { _ -> identityScopeChanges++ } + manager.identityBridge.onTargetingInvalidated = { externalUserId -> + targetingInvalidations++ + bridgedExternalUserId = externalUserId + } - manager.invalidateRemoteConfigsCache() + 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") } + 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. 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 index 7f8532f44..a9fa397de 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/QonversionInternalRemoteConfigV2WiringTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/QonversionInternalRemoteConfigV2WiringTest.kt @@ -70,6 +70,7 @@ internal class QonversionInternalRemoteConfigV2WiringTest { 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. @@ -147,7 +148,7 @@ internal class QonversionInternalRemoteConfigV2WiringTest { // A targeting invalidation re-reads targeting without a scope transition. identityBridge.targetingInvalidated() - verify(exactly = 1) { manager.refreshTargeting() } + verify(exactly = 1) { manager.refreshTargeting(null) } } @Test @@ -156,7 +157,7 @@ internal class QonversionInternalRemoteConfigV2WiringTest { every { manager.updateIdentity(any(), RemoteConfigFetchForceReason.Identify) } throws IllegalStateException("v2 refused the identity change") - every { manager.refreshTargeting() } throws IllegalStateException("v2 refused the refresh") + every { manager.refreshTargeting(null) } throws IllegalStateException("v2 refused the refresh") QonversionInternal(internalConfig(remoteConfigV2Config = v2Config()), RuntimeEnvironment.getApplication()) 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 index 72760a9f5..91dac4bd4 100644 --- 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 @@ -277,6 +277,33 @@ internal class QRemoteConfigsPublicApiTest { 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() @@ -325,6 +352,7 @@ internal class QRemoteConfigsPublicApiTest { 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) @@ -338,6 +366,7 @@ internal class QRemoteConfigsPublicApiTest { ), ) harness.fetchBlocking() + harness.awaitAcks(2) assertTrue("no update was delivered", latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) val update = updates.single() @@ -348,6 +377,7 @@ internal class QRemoteConfigsPublicApiTest { 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 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 index a58ffaf03..39786f34e 100644 --- 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 @@ -1,5 +1,8 @@ +@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 @@ -83,6 +86,35 @@ internal class RemoteConfigGatewayTransportTest { 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)) @@ -529,6 +561,7 @@ internal class RemoteConfigGatewayTransportTest { sessionStore: RemoteConfigSessionStore = store(), maxSnapshotBodyBytes: Long = REMOTE_CONFIG_SNAPSHOT_BODY_MAX_BYTES, projectIds: RemoteConfigProjectIdRegistry = registry(), + assertionProvider: QRemoteConfigIdentifyAssertionProvider? = null, ) = RemoteConfigGatewayTransport( callFactory = client, baseUrlProvider = { server.url("/").toString() }, @@ -537,6 +570,7 @@ internal class RemoteConfigGatewayTransportTest { sessionStore = sessionStore, projectIds = projectIds, clock = clock, + identifyAssertionProvider = assertionProvider, moshi = Moshi.Builder().build(), logger = logger, maxSnapshotBodyBytes = maxSnapshotBodyBytes, @@ -588,8 +622,11 @@ internal class RemoteConfigGatewayTransportTest { .setHeader("Content-Type", "application/json") .setBody(Buffer().write(body)) - private fun identityFor(scope: RemoteConfigSnapshotScope, userUid: String) = - RemoteConfigTransportIdentity(scope, PROJECT_TOKEN, userUid) + 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() { From 364855d20e7f4a493f0d9d74dd538b49b84109a1 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Fri, 14 Aug 2026 16:14:04 +0300 Subject: [PATCH 29/30] test(remote-config): align identity invalidation contracts --- .../QProductCenterManagerIdentifyContractTest.kt | 16 ++++++++-------- .../sdk/internal/QProductCenterManagerTest.kt | 14 +++++++------- 2 files changed, 15 insertions(+), 15 deletions(-) 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 f204d6412..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,8 +77,8 @@ 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()) } answers { - firstArg<() -> Unit>().invoke() + every { mockRemoteConfigManager.onUserUpdate(any(), any()) } answers { + secondArg<() -> Unit>().invoke() } // billingService.queryPurchases is the synchronous entry point @@ -140,7 +140,7 @@ internal class QProductCenterManagerIdentifyContractTest { // cache and finds stale permissions before clear, the UX is // broken. verifyOrder { - mockRemoteConfigManager.onUserUpdate(any()) + mockRemoteConfigManager.onUserUpdate(newIdentity, any()) mockConfig.uid = mergedUid mockLaunchResultCacheWrapper.clearPermissionsCache() mockRepository.init(match { it.requestTrigger == RequestTrigger.Identify }) @@ -179,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(any()) } + verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate(any(), any()) } } /** @@ -203,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(any()) } + 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 59c9ba65f..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,8 +60,8 @@ internal class QProductCenterManagerTest { mockInstallDate() every { mockHandledPurchasesCache.shouldHandlePurchase(any()) } returns true - every { mockRemoteConfigManager.onUserUpdate(any()) } answers { - firstArg<() -> Unit>().invoke() + every { mockRemoteConfigManager.onUserUpdate(any(), any()) } answers { + secondArg<() -> Unit>().invoke() } productCenterManager = QProductCenterManager( @@ -174,7 +174,7 @@ internal class QProductCenterManagerTest { productCenterManager.restore(RequestTrigger.Restore, callback) verify(exactly = 0) { mockUserInfoService.storeQonversionUserId(any()) } - verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate(any()) } + verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate(any(), any()) } verify(exactly = 0) { mockLaunchResultCacheWrapper.clearPermissionsCache() } verify { callback.onSuccess(any()) } } @@ -193,7 +193,7 @@ internal class QProductCenterManagerTest { verifyOrder { mockUserInfoService.storeQonversionUserId(originalOwnerUid) - mockRemoteConfigManager.onUserUpdate(any()) + mockRemoteConfigManager.onUserUpdate(null, any()) mockConfig.uid = originalOwnerUid mockLaunchResultCacheWrapper.clearPermissionsCache() } @@ -214,7 +214,7 @@ internal class QProductCenterManagerTest { verifyOrder { mockIdentityManager.logoutIfNeeded() mockUserInfoService.obtainUserId() - mockRemoteConfigManager.onUserUpdate(any()) + mockRemoteConfigManager.onUserUpdate(null, any()) mockConfig.uid = anonymousUid mockLaunchResultCacheWrapper.clearPermissionsCache() } @@ -242,7 +242,7 @@ internal class QProductCenterManagerTest { productCenterManager.restore(RequestTrigger.Restore, callback) verify(exactly = 0) { mockUserInfoService.storeQonversionUserId(any()) } - verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate(any()) } + verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate(any(), any()) } verify(exactly = 0) { mockLaunchResultCacheWrapper.clearPermissionsCache() } verify { callback.onError(any()) } } @@ -374,7 +374,7 @@ internal class QProductCenterManagerTest { productCenterManager.restore(RequestTrigger.Restore, callback) verify(exactly = 0) { mockUserInfoService.storeQonversionUserId(any()) } - verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate(any()) } + verify(exactly = 0) { mockRemoteConfigManager.onUserUpdate(any(), any()) } verify(exactly = 0) { mockLaunchResultCacheWrapper.clearPermissionsCache() } verify { callback.onSuccess(any()) } } From 078c76542b07e198be8e9282fdeb4a6d32d716e0 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Fri, 14 Aug 2026 16:25:54 +0300 Subject: [PATCH 30/30] fix(sample): satisfy paywall demo quality gate --- .../io/qonversion/sample/PaywallConfig.kt | 7 ++- .../qonversion/sample/PaywallDemoFragment.kt | 61 +++++++++---------- .../sample/RemoteConfigV2Adapter.kt | 3 +- 3 files changed, 36 insertions(+), 35 deletions(-) diff --git a/sample/src/main/java/io/qonversion/sample/PaywallConfig.kt b/sample/src/main/java/io/qonversion/sample/PaywallConfig.kt index 5665f9307..092a555a7 100644 --- a/sample/src/main/java/io/qonversion/sample/PaywallConfig.kt +++ b/sample/src/main/java/io/qonversion/sample/PaywallConfig.kt @@ -6,6 +6,7 @@ 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. */ @@ -80,7 +81,7 @@ const val PAYWALL_FALLBACK_ACCENT_COLOR = 0xFF7C5CFF.toInt() */ fun PaywallConfig.accentColorOrDefault(): Int = try { Color.parseColor(accentColor) -} catch (e: IllegalArgumentException) { +} catch (_: IllegalArgumentException) { PAYWALL_FALLBACK_ACCENT_COLOR } @@ -88,7 +89,7 @@ fun PaywallConfig.accentColorOrDefault(): Int = try { fun PaywallConfig.hasValidAccentColor(): Boolean = try { Color.parseColor(accentColor) true -} catch (e: IllegalArgumentException) { +} catch (_: IllegalArgumentException) { false } @@ -118,7 +119,7 @@ val PaywallConfigDecoder = QRemoteConfigDecoder { rawJson -> products = products, highlightProductId = root.optNullableString("highlightProductId").orEmpty(), ) - } catch (e: Exception) { + } catch (_: JSONException) { // A decoder must never crash the read: any malformed payload is simply not a candidate. null } diff --git a/sample/src/main/java/io/qonversion/sample/PaywallDemoFragment.kt b/sample/src/main/java/io/qonversion/sample/PaywallDemoFragment.kt index a07914414..68e1528e1 100644 --- a/sample/src/main/java/io/qonversion/sample/PaywallDemoFragment.kt +++ b/sample/src/main/java/io/qonversion/sample/PaywallDemoFragment.kt @@ -29,6 +29,7 @@ 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 @@ -329,37 +330,35 @@ class PaywallDemoFragment : Fragment() { } private fun renderCountdown(config: PaywallConfig, accent: Int) { - val binding = _binding ?: return - - val enabled = config.showCountdown && config.countdownSeconds > 0 - if (!enabled) { - countdownHandler.removeCallbacks(countdownTick) - countdownTotalSeconds = 0 - countdownRemainingSeconds = 0 - binding.countdownContainer.visibility = View.GONE - return - } - - 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() - return + _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) + } + } } - - countdownHandler.removeCallbacks(countdownTick) - countdownTotalSeconds = config.countdownSeconds - countdownRemainingSeconds = config.countdownSeconds - renderCountdownValue() - countdownHandler.postDelayed(countdownTick, COUNTDOWN_TICK_MS) } private fun renderCountdownValue() { @@ -467,7 +466,7 @@ class PaywallDemoFragment : Fragment() { experiment != null -> experiment else -> null } - } catch (e: Exception) { + } catch (_: JSONException) { null } diff --git a/sample/src/main/java/io/qonversion/sample/RemoteConfigV2Adapter.kt b/sample/src/main/java/io/qonversion/sample/RemoteConfigV2Adapter.kt index 495d70f13..07b8787f1 100644 --- a/sample/src/main/java/io/qonversion/sample/RemoteConfigV2Adapter.kt +++ b/sample/src/main/java/io/qonversion/sample/RemoteConfigV2Adapter.kt @@ -12,6 +12,7 @@ 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. */ @@ -76,7 +77,7 @@ class RemoteConfigV2Adapter( trimmed.startsWith("[") -> JSONArray(trimmed).toString(2) else -> raw } - } catch (e: Exception) { + } catch (_: JSONException) { raw }