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" + } +}