From 3de2672c28a3036e639bb9bbafe89e1ae179e3cc Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Fri, 7 Aug 2026 13:23:56 +0300 Subject: [PATCH 1/3] feat(remote-config): expose the v2 snapshot API Adds the customer-facing Remote Config v2 surface on top of the internal snapshot core, fetch coordinator and gateway transport landed in the previous slices. Nothing here re-implements them: the public types are thin, immutable adapters over the resolution ladder, the read guard and the coordinator's waiter model. The surface (all marked @ExperimentalQonversionApi, so it is not yet a stability promise): - Qonversion.remoteConfigSnapshots() -> QRemoteConfigSnapshots, with fetch(timeoutMs?), activate(), fetchAndActivate(), an immutable `current` snapshot, a synchronous bundled-fallback getter and subscribeOnConfigUpdate(). - Reads return {value, source}: raw JSON, an opaque JSON tree, or a caller-decoded type. The decoder is the per-key validator seam, so a rejected value falls to the previously activated release (cache) and then to the bundled defaults. - fetch's timeout bounds the wait, not the request: the completion reports the best available snapshot while the request keeps running and is still admitted when it lands. - Activation stays a whole-release atomic swap; an immediate-policy release performs that same swap on admission and notifies subscribers with the changed-key diff and per-key metadata. - Identity changes switch the scope synchronously (the previous identity's release is never readable afterwards) and force a fetch; an identify that only attaches an external id re-reads targeting without dropping the served release. The pipeline is dormant unless the app passes a QRemoteConfigV2Config: without it no store, thread, HTTP client or base URL is constructed, and every fetch completes with NotConfigured. There is no default endpoint. Tests cover the contract end to end against a real MockWebServer with the real core, guard and coordinator: timeout semantics, change detection, all three ladder positions, raw vs typed reads, the pre-activate fallback getter, subscription diffs, immediate auto-activation, the identity switch (old snapshot excluded, per-identity session, install date pinned against a real PackageManager) and main-thread delivery. Claude-Session: https://claude.ai/code/session_018pHXqfbxkMQJFzUZ3jW4A8 --- .../android/sdk/ExperimentalQonversionApi.kt | 26 + .../android/sdk/QRemoteConfigSnapshots.kt | 93 +++ .../com/qonversion/android/sdk/Qonversion.kt | 17 + .../android/sdk/QonversionConfig.kt | 26 +- .../QRemoteConfigActivationResult.kt | 19 + .../remoteconfig/QRemoteConfigApplyPolicy.kt | 21 + .../dto/remoteconfig/QRemoteConfigDecoder.kt | 22 + .../remoteconfig/QRemoteConfigFetchResult.kt | 20 + .../remoteconfig/QRemoteConfigFetchStatus.kt | 37 ++ .../dto/remoteconfig/QRemoteConfigSnapshot.kt | 96 +++ .../dto/remoteconfig/QRemoteConfigSource.kt | 25 + .../remoteconfig/QRemoteConfigSubscription.kt | 13 + .../dto/remoteconfig/QRemoteConfigUpdate.kt | 35 ++ .../dto/remoteconfig/QRemoteConfigV2Config.kt | 47 ++ .../dto/remoteconfig/QRemoteConfigValue.kt | 22 + .../android/sdk/internal/InternalConfig.kt | 8 +- .../sdk/internal/QRemoteConfigManager.kt | 10 +- .../sdk/internal/QonversionInternal.kt | 29 + .../remoteconfig/MainThreadDispatcher.kt | 26 + .../QRemoteConfigSnapshotsImpl.kt | 83 +++ .../RemoteConfigIdentityBridge.kt | 36 ++ .../remoteconfig/RemoteConfigV2Factory.kt | 190 ++++++ .../remoteconfig/RemoteConfigV2Manager.kt | 323 ++++++++++ .../services/BundledRemoteConfigDefaults.kt | 12 + .../listeners/QRemoteConfigUpdateListener.kt | 15 + ...onversionRemoteConfigActivationCallback.kt | 12 + .../QonversionRemoteConfigFetchCallback.kt | 12 + .../remoteconfig/QRemoteConfigV2ConfigTest.kt | 60 ++ .../QRemoteConfigsPublicApiTest.kt | 554 ++++++++++++++++++ .../RemoteConfigFetchCoordinatorTest.kt | 6 + .../RemoteConfigV2DeviceScopeTest.kt | 70 +++ .../remoteconfig/RemoteConfigV2TestHarness.kt | 439 ++++++++++++++ 32 files changed, 2399 insertions(+), 5 deletions(-) create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/ExperimentalQonversionApi.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/QRemoteConfigSnapshots.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigActivationResult.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigApplyPolicy.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigDecoder.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigFetchResult.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigFetchStatus.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSnapshot.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSource.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSubscription.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigUpdate.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2Config.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigValue.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/MainThreadDispatcher.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigSnapshotsImpl.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigIdentityBridge.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Manager.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/listeners/QRemoteConfigUpdateListener.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/listeners/QonversionRemoteConfigActivationCallback.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/listeners/QonversionRemoteConfigFetchCallback.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2ConfigTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigsPublicApiTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2DeviceScopeTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt diff --git a/sdk/src/main/java/com/qonversion/android/sdk/ExperimentalQonversionApi.kt b/sdk/src/main/java/com/qonversion/android/sdk/ExperimentalQonversionApi.kt new file mode 100644 index 000000000..1f52a492b --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/ExperimentalQonversionApi.kt @@ -0,0 +1,26 @@ +package com.qonversion.android.sdk + +/** + * Marks a Qonversion API that is still taking shape. + * + * A declaration annotated with this marker is shipped so integrators can try it, but it is + * explicitly **not** a stability promise: its signature, semantics and even its existence may + * change in any release without a deprecation cycle. + * + * Kotlin callers opt in with `@OptIn(ExperimentalQonversionApi::class)`; Java callers can use the + * API directly, since the opt-in requirement is a Kotlin compiler concept only. + */ +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = "This Qonversion API is experimental. Its behavior and signature may change " + + "without notice. Opt in with @OptIn(ExperimentalQonversionApi::class).", +) +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.TYPEALIAS, +) +annotation class ExperimentalQonversionApi diff --git a/sdk/src/main/java/com/qonversion/android/sdk/QRemoteConfigSnapshots.kt b/sdk/src/main/java/com/qonversion/android/sdk/QRemoteConfigSnapshots.kt new file mode 100644 index 000000000..44f2034af --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/QRemoteConfigSnapshots.kt @@ -0,0 +1,93 @@ +package com.qonversion.android.sdk + +import com.qonversion.android.sdk.dto.QRemoteConfigFallbackValue +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSnapshot +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSubscription +import com.qonversion.android.sdk.listeners.QRemoteConfigUpdateListener +import com.qonversion.android.sdk.listeners.QonversionRemoteConfigActivationCallback +import com.qonversion.android.sdk.listeners.QonversionRemoteConfigFetchCallback + +/** + * The Remote Config v2 snapshot API. + * + * The model is fetch/activate, not fetch/serve: a fetch only makes a release *available*, and + * [activate] swaps the whole release atomically into [current]. Values therefore never change + * under a running screen unless the app asks for it — or unless the release itself declares the + * immediate apply policy, in which case the SDK performs the same whole-release swap on admission + * and notifies [subscribeOnConfigUpdate] listeners. + * + * Every callback of this API is delivered on the main thread, exactly once. Reads ([current], + * [fallbackRemoteConfigValue]) are synchronous and safe from any thread. + * + * The API is dormant unless the app passes a `QRemoteConfigV2Config` to + * `QonversionConfig.Builder.setRemoteConfigV2Config`. While dormant there is no release at all: + * fetches complete with `NotConfigured`, [current] is empty (it does **not** fall back to the + * bundled defaults, because there is no scope to resolve them for), subscriptions never fire, and + * [fallbackRemoteConfigValue] keeps answering because it reads the app asset directly. + */ +@ExperimentalQonversionApi +interface QRemoteConfigSnapshots { + + /** + * The release that is currently activated. + * + * Reading before the first [activate] is a supported but flagged path: in a debug build the + * SDK reports it loudly (read-before-activate), and in a release build it silently performs a + * single implicit activation so the app is never served an empty config by accident. + */ + val current: QRemoteConfigSnapshot + + /** + * Fetches a release using the SDK's default timeout. + * + * @param callback delivered with the best available data — freshly fetched, previously + * activated, or bundled — and the fetch status. + */ + fun fetch(callback: QonversionRemoteConfigFetchCallback) + + /** + * Fetches a release, giving up on *waiting* after [timeoutMs]. + * + * On timeout the callback fires with `TimedOut` and the best available snapshot, while the + * request itself keeps running: if it succeeds later, the release is admitted as usual and + * becomes available to the next [activate]. + * + * @param timeoutMs how long to wait for the completion, in milliseconds. A non-positive value + * waives the caller's own deadline; the SDK still applies an internal ceiling (30 seconds), so + * a completion always arrives. + */ + fun fetch(timeoutMs: Long, callback: QonversionRemoteConfigFetchCallback) + + /** + * Atomically swaps the last fetched release into [current]. + * + * @param callback delivered with `changed = true` when the activated release differs from the + * previously activated one. + */ + fun activate(callback: QonversionRemoteConfigActivationCallback) + + /** Runs [fetch] and then [activate], delivering the activation result. */ + fun fetchAndActivate(callback: QonversionRemoteConfigActivationCallback) + + /** [fetchAndActivate] with an explicit fetch timeout — see [fetch]. */ + fun fetchAndActivate(timeoutMs: Long, callback: QonversionRemoteConfigActivationCallback) + + /** + * Reads a value directly from the Remote Config defaults bundled with the app. + * + * Synchronous and independent of networking, identity, caches and activation, so it answers + * before the first fetch or activate. Returns `null` when the key is absent from the bundle or + * the bundle failed strict validation. + */ + fun fallbackRemoteConfigValue(contextKey: String): QRemoteConfigFallbackValue? + + /** + * Subscribes to config updates: the changed-key diff plus the release that became current. + * + * While the pipeline is dormant the subscription is inert: nothing is ever fetched or + * activated, so no update can be delivered. + * + * @return a handle to stop receiving updates. + */ + fun subscribeOnConfigUpdate(listener: QRemoteConfigUpdateListener): QRemoteConfigSubscription +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt b/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt index 1f0787450..3ca2c068a 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt @@ -97,6 +97,23 @@ interface Qonversion { } } + /** + * The experimental Remote Config v2 snapshot API: fetch, activate, and read an immutable + * release whose every value reports its own source (server, cache or bundled fallback). + * + * Unrelated to [remoteConfig] / [remoteConfigList], which serve the v1 pipeline. + * + * Always returns a usable object. If the app did not pass a + * [com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigV2Config] to + * [QonversionConfig.Builder.setRemoteConfigV2Config], the pipeline is dormant: fetches + * complete with `NotConfigured`, `current` is empty, and only + * [QRemoteConfigSnapshots.fallbackRemoteConfigValue] answers. + * + * @see QRemoteConfigSnapshots + */ + @ExperimentalQonversionApi + fun remoteConfigSnapshots(): QRemoteConfigSnapshots + /** * Call this function to sync the subscriber data with the first launch * when Qonversion is implemented. diff --git a/sdk/src/main/java/com/qonversion/android/sdk/QonversionConfig.kt b/sdk/src/main/java/com/qonversion/android/sdk/QonversionConfig.kt index aa727bf4e..44a184a4b 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/QonversionConfig.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/QonversionConfig.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalQonversionApi::class) + package com.qonversion.android.sdk import android.app.Application @@ -7,6 +9,7 @@ import com.qonversion.android.sdk.dto.QLaunchMode import android.content.Context import androidx.annotation.RawRes import com.qonversion.android.sdk.dto.entitlements.QEntitlementsCacheLifetime +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigV2Config import com.qonversion.android.sdk.internal.EntitlementsUpdateListenerAdapter import com.qonversion.android.sdk.internal.dto.config.CacheConfig import com.qonversion.android.sdk.internal.dto.config.PrimaryConfig @@ -28,7 +31,8 @@ class QonversionConfig internal constructor( internal val application: Application, internal val primaryConfig: PrimaryConfig, internal val cacheConfig: CacheConfig, - internal val deferredPurchasesListener: QDeferredPurchasesListener? = null + internal val deferredPurchasesListener: QDeferredPurchasesListener? = null, + internal val remoteConfigV2Config: QRemoteConfigV2Config? = null ) { /** @@ -53,6 +57,7 @@ class QonversionConfig internal constructor( internal var proxyUrl: String? = null internal var isKidsMode: Boolean = false internal var sendFbAttribution: Boolean = true + internal var remoteConfigV2Config: QRemoteConfigV2Config? = null @RawRes internal var fallbackFileIdentifier: Int? = null @@ -145,6 +150,22 @@ class QonversionConfig internal constructor( } } + /** + * Enables the experimental Remote Config v2 snapshot pipeline. + * + * Without this call the pipeline stays dormant: the SDK creates no v2 storage, starts no + * background workers and contacts no v2 endpoint. There is no default base URL — the whole + * feature is opt-in per app. + * + * @param config addressing of the Remote Config v2 gateway. + * @return builder instance for chain calls. + * @see Qonversion.remoteConfigs + */ + @ExperimentalQonversionApi + fun setRemoteConfigV2Config(config: QRemoteConfigV2Config): Builder = apply { + this.remoteConfigV2Config = config + } + /** * Use this function to enable Qonversion SDK Kids mode. * With this mode activated, our SDK does not collect any information that violates Google Children's Privacy Policy. @@ -186,7 +207,8 @@ class QonversionConfig internal constructor( context.application, primaryConfig, cacheConfig, - deferredPurchasesListener + deferredPurchasesListener, + remoteConfigV2Config ) } } diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigActivationResult.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigActivationResult.kt new file mode 100644 index 000000000..41841a20d --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigActivationResult.kt @@ -0,0 +1,19 @@ +package com.qonversion.android.sdk.dto.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi + +/** + * The completion value of an activation. + * + * @param changed `true` when this activation made at least one key differ from the previously + * activated release — i.e. "something changed since the last activation". + * @param snapshot the snapshot that is current after the activation. + * @param fetchStatus outcome of the fetch that preceded the activation, or `null` when the + * activation was requested on its own. + */ +@ExperimentalQonversionApi +class QRemoteConfigActivationResult internal constructor( + val changed: Boolean, + val snapshot: QRemoteConfigSnapshot, + val fetchStatus: QRemoteConfigFetchStatus? = null, +) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigApplyPolicy.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigApplyPolicy.kt new file mode 100644 index 000000000..433bfb250 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigApplyPolicy.kt @@ -0,0 +1,21 @@ +package com.qonversion.android.sdk.dto.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi + +/** + * How a fetched release asks to be applied. + * + * Activation is always a full atomic swap of the whole release — the policy decides *when* that + * swap happens, never *which part* of the release is swapped. + */ +@ExperimentalQonversionApi +enum class QRemoteConfigApplyPolicy { + /** The release becomes current only when the app calls `activate()`. */ + OnNextActivate, + + /** + * The release is activated as soon as it is admitted. A single immediate key activates the + * whole release, since a release is never applied partially. + */ + Immediate, +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigDecoder.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigDecoder.kt new file mode 100644 index 000000000..69f5b8e85 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigDecoder.kt @@ -0,0 +1,22 @@ +package com.qonversion.android.sdk.dto.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi + +/** + * Decodes one raw Remote Config JSON value into an app type. + * + * The decoder is the per-key validator seam of the snapshot: returning `null` (or throwing) means + * "this raw value is not usable for this key", which makes the read fall to the next position of + * the resolution ladder — the previously activated value, then the bundled default. + * + * Implementations must be deterministic and side-effect free: the same raw JSON is decoded again + * on later reads, and a decoder that answers differently over time makes reads unstable. + */ +@ExperimentalQonversionApi +fun interface QRemoteConfigDecoder { + /** + * @param rawJson the exact JSON text stored for the key. + * @return the decoded value, or `null` to reject this raw value. + */ + fun decode(rawJson: String): T? +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigFetchResult.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigFetchResult.kt new file mode 100644 index 000000000..d77ad9072 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigFetchResult.kt @@ -0,0 +1,20 @@ +package com.qonversion.android.sdk.dto.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi + +/** + * The completion value of a fetch. + * + * [snapshot] is the best available data at completion time: the last fetched release when one is + * held (which, on a successful fetch, is the release this call just brought in), otherwise the + * currently activated release, otherwise the bundled defaults. Each key read from it still reports + * its own [QRemoteConfigSource], including on a [QRemoteConfigFetchStatus.TimedOut] completion. + * + * It is therefore a *fetch* view, not the activated one: it can show a release that + * `QRemoteConfigSnapshots.current` will only serve after the next `activate()`. + */ +@ExperimentalQonversionApi +class QRemoteConfigFetchResult internal constructor( + val status: QRemoteConfigFetchStatus, + val snapshot: QRemoteConfigSnapshot, +) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigFetchStatus.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigFetchStatus.kt new file mode 100644 index 000000000..17537664a --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigFetchStatus.kt @@ -0,0 +1,37 @@ +package com.qonversion.android.sdk.dto.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi + +/** + * Outcome of a Remote Config fetch attempt. + * + * None of these statuses changes what `QRemoteConfigSnapshots.current` returns: a fetched release becomes + * current only through `activate()` — or immediately, when the release itself asks for it via + * [QRemoteConfigApplyPolicy.Immediate]. + */ +@ExperimentalQonversionApi +enum class QRemoteConfigFetchStatus { + /** A new release was fetched and admitted. */ + Fetched, + + /** The server confirmed the held release is still current. */ + NotModified, + + /** + * The caller's timeout elapsed first. The request keeps running in the background, and its + * result is admitted when it arrives — it is simply no longer awaited. + */ + TimedOut, + + /** The minimum fetch interval or a failure backoff blocked the attempt. */ + Throttled, + + /** The attempt failed. */ + Failed, + + /** An identity change replaced the scope this fetch belonged to. */ + Superseded, + + /** Remote Config v2 is not configured for this app, so no fetch was attempted. */ + NotConfigured, +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSnapshot.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSnapshot.kt new file mode 100644 index 000000000..2b1f4f23c --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSnapshot.kt @@ -0,0 +1,96 @@ +@file:OptIn(ExperimentalQonversionApi::class) + +package com.qonversion.android.sdk.dto.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi +import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigResolvedValue +import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshot +import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshotApplyPolicy +import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshotValueSource +import com.qonversion.android.sdk.internal.services.decodePortableRemoteConfigJson + +/** + * An immutable view of one Remote Config release. + * + * A snapshot never changes: holding it lets an app read several keys that are guaranteed to belong + * to the same release, even if another release is activated meanwhile. Take a fresh snapshot from + * `QRemoteConfigSnapshots.current` to observe a newer activation. + * + * Every read answers with a [QRemoteConfigValue] carrying the value **and** its + * [QRemoteConfigSource], or `null` when no ladder position could produce a value: the key is + * unknown to both the release and the bundled defaults, it was explicitly deleted from the release + * and has no bundled default, or — for a typed read — every candidate was rejected by the decoder. + */ +@ExperimentalQonversionApi +class QRemoteConfigSnapshot internal constructor( + private val snapshot: RemoteConfigSnapshot, +) { + /** Identifier of the release this snapshot holds, or an empty string for a fallback-only one. */ + val releaseUid: String get() = snapshot.releaseUid + + /** Monotonic number of the release this snapshot holds, or `0` for a fallback-only one. */ + val releaseNumber: Long get() = snapshot.releaseNumber + + /** Every context key readable from this snapshot, including keys served by bundled defaults. */ + val contextKeys: Set get() = snapshot.allKeys + + /** + * Reads [contextKey] as the exact JSON text stored for it. + * + * Raw reads never reject a value, so their source is [QRemoteConfigSource.Server] or + * [QRemoteConfigSource.Fallback] — [QRemoteConfigSource.Cache] is reachable only through a + * typed read whose decoder rejected the current release's value. + */ + fun rawValue(contextKey: String): QRemoteConfigValue? = + snapshot.rawValue(contextKey)?.toPublicValue { bytes -> bytes.toString(Charsets.UTF_8) } + + /** + * Reads [contextKey] as an opaque JSON tree: a [Map], [List], [String], [Double], [Boolean], + * or `null` for a JSON `null`. + * + * The wrapper stays non-null for a present JSON `null`, so an explicit null value remains + * distinguishable from a missing key. + */ + fun jsonValue(contextKey: String): QRemoteConfigValue? = + snapshot.value(contextKey) { bytes -> decodePortableRemoteConfigJson(bytes) } + ?.toPublicValue { decoded -> decoded.value } + + /** + * Reads [contextKey] through [decoder]. + * + * A decoder that returns `null` (or throws) rejects the value and the read falls to the next + * resolution-ladder position, which is what makes [QRemoteConfigSource.Cache] observable. + */ + fun value(contextKey: String, decoder: QRemoteConfigDecoder): QRemoteConfigValue? = + snapshot.value(contextKey) { bytes -> decoder.decode(bytes.toString(Charsets.UTF_8)) } + ?.toPublicValue { decoded -> decoded } + + private fun RemoteConfigResolvedValue.toPublicValue( + transform: (T) -> R, + ): QRemoteConfigValue = QRemoteConfigValue( + value = transform(value), + source = source.toPublicSource(), + variationUid = variationUid, + applyPolicy = applyPolicy.toPublicApplyPolicy(), + metadataJson = metadataBytes.toMetadataJson(), + ) +} + +/** + * A release always carries a `metadata` member, and "no metadata" is spelled as the JSON literal + * `null` on the wire. Collapsing it to a Kotlin `null` keeps `metadataJson != null` meaning + * "there is metadata" for both server-served and bundled values. + */ +internal fun ByteArray?.toMetadataJson(): String? = + this?.toString(Charsets.UTF_8)?.takeUnless { it == "null" } + +internal fun RemoteConfigSnapshotValueSource.toPublicSource(): QRemoteConfigSource = when (this) { + RemoteConfigSnapshotValueSource.Server -> QRemoteConfigSource.Server + RemoteConfigSnapshotValueSource.Cache -> QRemoteConfigSource.Cache + RemoteConfigSnapshotValueSource.Fallback -> QRemoteConfigSource.Fallback +} + +internal fun RemoteConfigSnapshotApplyPolicy.toPublicApplyPolicy(): QRemoteConfigApplyPolicy = when (this) { + RemoteConfigSnapshotApplyPolicy.OnNextActivate -> QRemoteConfigApplyPolicy.OnNextActivate + RemoteConfigSnapshotApplyPolicy.Immediate -> QRemoteConfigApplyPolicy.Immediate +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSource.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSource.kt new file mode 100644 index 000000000..aeb2ec21c --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSource.kt @@ -0,0 +1,25 @@ +package com.qonversion.android.sdk.dto.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi + +/** + * Where a resolved Remote Config value came from. + * + * The resolution ladder is always tried in this order: [Server], then [Cache], then [Fallback]. + * Every read result carries its position on that ladder, so a caller can tell a freshly targeted + * value from a value that survived a failed decode or from the defaults bundled with the app. + */ +@ExperimentalQonversionApi +enum class QRemoteConfigSource { + /** The value carried by the release this snapshot holds. */ + Server, + + /** + * The previously activated release's value, reused because this snapshot's own value did not + * survive the caller-supplied decode. Only reachable for typed reads. + */ + Cache, + + /** The value from the Remote Config defaults bundled with the app. */ + Fallback, +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSubscription.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSubscription.kt new file mode 100644 index 000000000..7681777ea --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigSubscription.kt @@ -0,0 +1,13 @@ +package com.qonversion.android.sdk.dto.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi + +/** + * Handle of a config-update subscription. + * + * Call [remove] to stop receiving updates. Removing twice is safe. + */ +@ExperimentalQonversionApi +fun interface QRemoteConfigSubscription { + fun remove() +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigUpdate.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigUpdate.kt new file mode 100644 index 000000000..d0d237b6b --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigUpdate.kt @@ -0,0 +1,35 @@ +package com.qonversion.android.sdk.dto.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi +import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigSnapshotUpdate + +/** + * Describes one activation delivered to a config-update listener. + * + * The update is always a whole-release swap: [changedKeys] is the diff against the previously + * activated release, and [snapshot] is the complete release that is now current. + */ +@ExperimentalQonversionApi +class QRemoteConfigUpdate internal constructor( + private val update: RemoteConfigSnapshotUpdate, +) { + /** The release that became current with this activation. */ + val snapshot: QRemoteConfigSnapshot = QRemoteConfigSnapshot(update.snapshot) + + /** Keys whose effective value differs from the previously activated release. */ + val changedKeys: Set get() = update.changedKeys + + /** Raw JSON metadata attached to a changed key, or `null` when the key carries none. */ + fun metadataJson(contextKey: String): String? = + update.metadataForKey(contextKey).toMetadataJson() + + /** + * Apply policy declared for [contextKey] by the release that just became current, or `null` + * when the key is not readable from it. + * + * A key with [QRemoteConfigApplyPolicy.Immediate] means this update was delivered without the + * app calling `activate()` — the whole release was swapped atomically on admission. + */ + fun applyPolicy(contextKey: String): QRemoteConfigApplyPolicy? = + snapshot.rawValue(contextKey)?.applyPolicy +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2Config.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2Config.kt new file mode 100644 index 000000000..20fac0712 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2Config.kt @@ -0,0 +1,47 @@ +package com.qonversion.android.sdk.dto.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi + +private const val REMOTE_CONFIG_V2_UID_MAX_CODE_POINTS = 36 +private val LOWERCASE_SHA256_PATTERN = Regex("^[0-9a-f]{64}$") + +/** + * Enables the experimental Remote Config v2 snapshot pipeline. + * + * The pipeline is **dormant** unless this configuration is passed to + * `QonversionConfig.Builder.setRemoteConfigV2Config`: without it the SDK builds no v2 store, opens + * no v2 connection, and `QRemoteConfigSnapshots` answers every fetch with + * [QRemoteConfigFetchStatus.NotConfigured] while still serving bundled defaults. There is no + * default base URL and no production endpoint is contacted implicitly. + * + * @param baseUrl base URL of the Remote Config v2 gateway, e.g. `https://host/`. The SDK appends + * its own paths, so a bare origin is expected. + * @param environmentUid uid of the Remote Config environment to read. + * @param projectId numeric project id the served snapshots must belong to. + * @param contextFingerprint the snapshot context fingerprint the gateway resolves for this + * integration. It binds an admitted snapshot to the targeting context it was resolved for, and the + * SDK cannot derive it — the value is server-side keyed. It is a temporary integration hand-off: + * once the gateway returns the fingerprint on session bootstrap, this parameter goes away. + * @throws IllegalArgumentException if any value is malformed. + */ +@ExperimentalQonversionApi +class QRemoteConfigV2Config( + val baseUrl: String, + val environmentUid: String, + val projectId: Long, + val contextFingerprint: String, +) { + init { + require(baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { + "Remote Config v2 base url must be an absolute http(s) url" + } + require( + environmentUid.isNotEmpty() && + environmentUid.codePointCount(0, environmentUid.length) <= REMOTE_CONFIG_V2_UID_MAX_CODE_POINTS, + ) { "Remote Config v2 environment uid must be 1..$REMOTE_CONFIG_V2_UID_MAX_CODE_POINTS code points" } + require(projectId > 0) { "Remote Config v2 project id must be positive" } + require(LOWERCASE_SHA256_PATTERN.matches(contextFingerprint)) { + "Remote Config v2 context fingerprint must be 64 lowercase hexadecimal characters" + } + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigValue.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigValue.kt new file mode 100644 index 000000000..b193b323f --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigValue.kt @@ -0,0 +1,22 @@ +package com.qonversion.android.sdk.dto.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi + +/** + * One resolved Remote Config read: the value plus where it came from. + * + * @param value the decoded value. + * @param source the resolution-ladder position [value] was taken from. + * @param variationUid identifier of the variation the value belongs to. + * @param applyPolicy the apply policy declared for this key by the release it came from. + * @param metadataJson raw JSON metadata attached to the key, or `null` when the release + * declares none (the JSON literal `null` on the wire is reported as a Kotlin `null`). + */ +@ExperimentalQonversionApi +class QRemoteConfigValue internal constructor( + val value: T, + val source: QRemoteConfigSource, + val variationUid: String, + val applyPolicy: QRemoteConfigApplyPolicy, + val metadataJson: String?, +) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/InternalConfig.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/InternalConfig.kt index bab094bd5..fe597852b 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/InternalConfig.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/InternalConfig.kt @@ -1,6 +1,7 @@ package com.qonversion.android.sdk.internal import com.qonversion.android.sdk.QonversionConfig +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigV2Config import com.qonversion.android.sdk.internal.dto.config.PrimaryConfig import com.qonversion.android.sdk.dto.QEnvironment import com.qonversion.android.sdk.dto.QLaunchMode @@ -11,10 +12,12 @@ import com.qonversion.android.sdk.internal.provider.PrimaryConfigProvider import com.qonversion.android.sdk.internal.provider.UidProvider import com.qonversion.android.sdk.listeners.QDeferredPurchasesListener +@OptIn(com.qonversion.android.sdk.ExperimentalQonversionApi::class) internal class InternalConfig( override var primaryConfig: PrimaryConfig, override val cacheConfig: CacheConfig, - var deferredPurchasesListener: QDeferredPurchasesListener? = null + var deferredPurchasesListener: QDeferredPurchasesListener? = null, + val remoteConfigV2Config: QRemoteConfigV2Config? = null ) : EnvironmentProvider, PrimaryConfigProvider, CacheConfigProvider, @@ -33,7 +36,8 @@ internal class InternalConfig( constructor(qonversionConfig: QonversionConfig) : this( qonversionConfig.primaryConfig, qonversionConfig.cacheConfig, - qonversionConfig.deferredPurchasesListener + qonversionConfig.deferredPurchasesListener, + qonversionConfig.remoteConfigV2Config ) override val apiUrl: String diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/QRemoteConfigManager.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/QRemoteConfigManager.kt index 75cc39122..abcb6f407 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/QRemoteConfigManager.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/QRemoteConfigManager.kt @@ -8,6 +8,7 @@ import com.qonversion.android.sdk.dto.QRemoteConfigList import com.qonversion.android.sdk.dto.QonversionError import com.qonversion.android.sdk.dto.QonversionErrorCode import com.qonversion.android.sdk.internal.provider.UserStateProvider +import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigIdentityBridge import com.qonversion.android.sdk.internal.services.QFallbacksService import com.qonversion.android.sdk.internal.services.QRemoteConfigService import com.qonversion.android.sdk.internal.storage.RemoteConfigCache @@ -107,6 +108,9 @@ internal class QRemoteConfigManager @Inject constructor( private val deliveryOrigins = mutableMapOf() private val listRequests = mutableListOf() lateinit var userPropertiesManager: QUserPropertiesManager + + /** Observers of the identity/targeting transitions this manager owns (Remote Config v2). */ + internal val identityBridge = RemoteConfigIdentityBridge() private val mainHandler = Handler(Looper.getMainLooper()) private val identityTransitionLock = Any() @@ -158,7 +162,10 @@ internal class QRemoteConfigManager @Inject constructor( // stale so the next load fetches a fresh evaluation. Non-destructive — // loading states and pending callbacks survive, and the generation bump // stops in-flight loads from re-caching a superseded response. - fun invalidateRemoteConfigsCache() = invalidateOnAnyThread {} + fun invalidateRemoteConfigsCache() { + invalidateOnAnyThread {} + identityBridge.targetingInvalidated() + } fun onUserUpdate(updateIdentity: () -> Unit = {}) { // The generation and the UID mutation share one linearization point. @@ -168,6 +175,7 @@ internal class QRemoteConfigManager @Inject constructor( invalidationGeneration.incrementAndGet() userGeneration.incrementAndGet() updateIdentity() + identityBridge.identityScopeChanged() if (Looper.myLooper() == Looper.getMainLooper()) { resetIdentityStateIfNeeded() } else { diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionInternal.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionInternal.kt index d20eca24e..84d2627b0 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionInternal.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionInternal.kt @@ -6,6 +6,8 @@ import android.net.Uri import android.os.Handler import android.os.Looper import androidx.lifecycle.ProcessLifecycleOwner +import com.qonversion.android.sdk.ExperimentalQonversionApi +import com.qonversion.android.sdk.QRemoteConfigSnapshots import com.qonversion.android.sdk.Qonversion import com.qonversion.android.sdk.dto.QAttributionProvider import com.qonversion.android.sdk.dto.QPurchaseOptions @@ -25,6 +27,8 @@ import com.qonversion.android.sdk.internal.logger.ConsoleLogger import com.qonversion.android.sdk.internal.logger.ExceptionManager import com.qonversion.android.sdk.internal.provider.AppStateProvider import com.qonversion.android.sdk.internal.redemption.RedemptionManager +import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigFetchForceReason +import com.qonversion.android.sdk.internal.remoteconfig.RemoteConfigV2Factory import com.qonversion.android.sdk.internal.services.QFallbacksService import com.qonversion.android.sdk.internal.storage.SharedPreferencesCache import com.qonversion.android.sdk.listeners.QonversionExperimentAttachCallback @@ -46,6 +50,7 @@ import com.qonversion.android.sdk.dto.QPurchaseResult import com.qonversion.android.sdk.dto.QPurchaseResultStatus import com.qonversion.android.sdk.dto.QonversionErrorCode +@OptIn(ExperimentalQonversionApi::class) internal class QonversionInternal( internalConfig: InternalConfig, application: Application @@ -59,6 +64,7 @@ internal class QonversionInternal( private var sharedPreferencesCache: SharedPreferencesCache private var exceptionManager: ExceptionManager private var remoteConfigManager: QRemoteConfigManager + private val remoteConfigsV2: QRemoteConfigSnapshots private var fallbackService: QFallbacksService private val redemptionManager: RedemptionManager @@ -116,6 +122,27 @@ internal class QonversionInternal( remoteConfigManager.userPropertiesManager = userPropertiesManager + // Remote Config v2 is opt-in: with no QRemoteConfigV2Config the factory builds nothing but + // the (bundled-defaults only) public facade, so the pipeline stays completely dormant. + val remoteConfigsV2Impl = RemoteConfigV2Factory.create( + application, + internalConfig, + sharedPreferencesCache, + logger, + ) + remoteConfigsV2 = remoteConfigsV2Impl + remoteConfigsV2Impl.manager?.let { manager -> + manager.updateIdentity(internalConfig.uid, RemoteConfigFetchForceReason.Build) + // The v1 manager owns the identity transition; v2 switches its scope inside it, so the + // previous identity's release stops being readable at the same instant for both. + remoteConfigManager.identityBridge.onIdentityScopeChanged = { + manager.updateIdentity(internalConfig.uid, RemoteConfigFetchForceReason.Identify) + } + // Targeting can change without the uid changing — identify() that only attaches an + // external id, an experiment attach, or an explicit invalidation. Re-read, keep serving. + remoteConfigManager.identityBridge.onTargetingInvalidated = { manager.refreshTargeting() } + } + val lifecycleHandler = AppLifecycleHandler(this) postToMainThread { ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleHandler) } @@ -307,6 +334,8 @@ internal class QonversionInternal( }) } + override fun remoteConfigSnapshots(): QRemoteConfigSnapshots = remoteConfigsV2 + override fun invalidateRemoteConfigsCache() { remoteConfigManager.invalidateRemoteConfigsCache() } diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/MainThreadDispatcher.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/MainThreadDispatcher.kt new file mode 100644 index 000000000..dc1dabef7 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/MainThreadDispatcher.kt @@ -0,0 +1,26 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import android.os.Handler +import android.os.Looper + +/** + * Delivers Remote Config callbacks on the main thread. + * + * Mirrors `QonversionInternal.postToMainThread`: work already on the main thread runs inline, so a + * callback issued from the main thread is not deferred to the next loop iteration. + */ +internal class MainThreadDispatcher : RemoteConfigMainDispatcher { + private val handler = Handler(Looper.getMainLooper()) + + override fun post(action: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + action() + } else { + handler.post(action) + } + } + + override fun postDeferred(action: () -> Unit) { + handler.post(action) + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigSnapshotsImpl.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigSnapshotsImpl.kt new file mode 100644 index 000000000..94a4474ef --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigSnapshotsImpl.kt @@ -0,0 +1,83 @@ +@file:OptIn(ExperimentalQonversionApi::class) + +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi +import com.qonversion.android.sdk.QRemoteConfigSnapshots +import com.qonversion.android.sdk.dto.QRemoteConfigFallbackValue +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigActivationResult +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchResult +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchStatus +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSnapshot +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSubscription +import com.qonversion.android.sdk.listeners.QRemoteConfigUpdateListener +import com.qonversion.android.sdk.listeners.QonversionRemoteConfigActivationCallback +import com.qonversion.android.sdk.listeners.QonversionRemoteConfigFetchCallback + +/** + * Adapts [RemoteConfigV2Manager] to the public [QRemoteConfigSnapshots] surface. + * + * A `null` [manager] is the dormant configuration: no v2 store, no connection, no scope. There is + * no release to read, so [current] is empty; [fallbackRemoteConfigValue] still answers, because the + * bundled defaults are an app asset rather than part of the pipeline. Every fetch completes with + * [QRemoteConfigFetchStatus.NotConfigured] instead of silently doing nothing. + */ +internal class QRemoteConfigSnapshotsImpl( + internal val manager: RemoteConfigV2Manager?, + private val bundledValueReader: (String) -> QRemoteConfigFallbackValue?, + private val mainDispatcher: RemoteConfigMainDispatcher, +) : QRemoteConfigSnapshots { + + override val current: QRemoteConfigSnapshot + get() = manager?.current ?: emptySnapshot() + + override fun fetch(callback: QonversionRemoteConfigFetchCallback) = runFetch(null, callback) + + override fun fetch(timeoutMs: Long, callback: QonversionRemoteConfigFetchCallback) = + runFetch(timeoutMs, callback) + + override fun activate(callback: QonversionRemoteConfigActivationCallback) { + val target = manager ?: return mainDispatcher.post { + callback.onResult(notConfiguredActivation(null)) + } + target.activate { result -> callback.onResult(result) } + } + + override fun fetchAndActivate(callback: QonversionRemoteConfigActivationCallback) = + runFetchAndActivate(null, callback) + + override fun fetchAndActivate(timeoutMs: Long, callback: QonversionRemoteConfigActivationCallback) = + runFetchAndActivate(timeoutMs, callback) + + override fun fallbackRemoteConfigValue(contextKey: String): QRemoteConfigFallbackValue? = + bundledValueReader(contextKey) + + override fun subscribeOnConfigUpdate(listener: QRemoteConfigUpdateListener): QRemoteConfigSubscription { + val target = manager ?: return QRemoteConfigSubscription { } + return target.subscribeOnConfigUpdate { update -> listener.onRemoteConfigUpdated(update) } + } + + private fun runFetch(timeoutMs: Long?, callback: QonversionRemoteConfigFetchCallback) { + val target = manager ?: return mainDispatcher.post { + callback.onResult(QRemoteConfigFetchResult(QRemoteConfigFetchStatus.NotConfigured, emptySnapshot())) + } + target.fetch(timeoutMs) { result -> callback.onResult(result) } + } + + private fun runFetchAndActivate(timeoutMs: Long?, callback: QonversionRemoteConfigActivationCallback) { + val target = manager ?: return mainDispatcher.post { + callback.onResult(notConfiguredActivation(QRemoteConfigFetchStatus.NotConfigured)) + } + target.fetchAndActivate(timeoutMs) { result -> callback.onResult(result) } + } + + private fun notConfiguredActivation(fetchStatus: QRemoteConfigFetchStatus?) = QRemoteConfigActivationResult( + changed = false, + snapshot = emptySnapshot(), + fetchStatus = fetchStatus, + ) + + private fun emptySnapshot() = QRemoteConfigSnapshot( + RemoteConfigSnapshot(primaryRelease = null, previousRelease = null, bundledRelease = null), + ) +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigIdentityBridge.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigIdentityBridge.kt new file mode 100644 index 000000000..4fec8a918 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigIdentityBridge.kt @@ -0,0 +1,36 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +/** + * The two moments the v1 Remote Config pipeline owns and the v2 pipeline must observe. + * + * It exists as its own object rather than as fields on `QRemoteConfigManager` so the optional v2 + * subsystem adds one collaborator to that class instead of more surface, and so the "an optional + * subsystem can never break the v1 transition" rule is written once, here. + */ +internal class RemoteConfigIdentityBridge { + + /** + * The canonical uid changed (logout, or an identify that minted a new one). The v2 scope must + * switch immediately, dropping the previous identity's release. + */ + var onIdentityScopeChanged: (() -> Unit)? = null + + /** + * The targeting inputs changed while the identity stayed the same — an identify that only + * attached an external id, a user-property batch, an experiment attach/detach, or an explicit + * cache invalidation. The v2 pipeline must re-read targeting but keep serving its release. + */ + var onTargetingInvalidated: (() -> Unit)? = null + + fun identityScopeChanged() = notify(onIdentityScopeChanged) + + fun targetingInvalidated() = notify(onTargetingInvalidated) + + private fun notify(observer: (() -> Unit)?) { + try { + observer?.invoke() + } catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") _: RuntimeException) { + // An optional subsystem can never break the v1 identity transition or invalidation. + } + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt new file mode 100644 index 000000000..ca5dfd844 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt @@ -0,0 +1,190 @@ +@file:OptIn(ExperimentalQonversionApi::class) + +package com.qonversion.android.sdk.internal.remoteconfig + +import android.app.Application +import com.qonversion.android.sdk.ExperimentalQonversionApi +import com.qonversion.android.sdk.dto.QRemoteConfigFallbackValue +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigV2Config +import com.qonversion.android.sdk.internal.InternalConfig +import com.qonversion.android.sdk.internal.isDebuggable +import com.qonversion.android.sdk.internal.logger.Logger +import com.qonversion.android.sdk.internal.services.BundledRemoteConfigDefaults +import com.qonversion.android.sdk.internal.services.BundledRemoteConfigDefaultsReader +import com.qonversion.android.sdk.internal.storage.Cache +import com.qonversion.android.sdk.internal.storage.PersistentRemoteConfigSnapshotStore +import com.squareup.moshi.Moshi +import okhttp3.OkHttpClient +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ThreadFactory +import java.util.concurrent.TimeUnit +import kotlin.random.Random + +private const val REMOTE_CONFIG_V2_MINIMUM_FETCH_INTERVAL_MILLIS = 60_000L +private const val REMOTE_CONFIG_V2_TRANSPORT_TIMEOUT_SECONDS = 15L +private const val REMOTE_CONFIG_V2_REQUEST_TIMEOUT_MILLIS = 30_000L +private const val REMOTE_CONFIG_V2_WORKER_THREAD_NAME = "qonversion-remote-config-v2" +private const val REMOTE_CONFIG_V2_SCHEDULER_THREAD_NAME = "qonversion-remote-config-v2-timer" + +/** + * Builds the whole Remote Config v2 chain, or nothing at all. + * + * Nothing is constructed unless the app supplied a [QRemoteConfigV2Config]: no store, no + * background threads, no HTTP client, no base URL. This is the single switch that keeps the + * feature dormant, and there is deliberately no default endpoint to fall back to. + * + * The subsystem is assembled here rather than in the Dagger graph for the same reason + * `RedemptionManager` is: it owns its dependencies end to end (cache + moshi + logger + its own + * OkHttp client), and adding a module for one optional object would put a dormant feature into + * every graph build. + */ +internal object RemoteConfigV2Factory { + + fun create( + application: Application, + internalConfig: InternalConfig, + cache: Cache, + logger: Logger, + ): QRemoteConfigSnapshotsImpl { + val bundledReader: (String) -> QRemoteConfigFallbackValue? = { contextKey -> + BundledRemoteConfigDefaults.value(application, contextKey) + } + val config = internalConfig.remoteConfigV2Config + val mainDispatcher = MainThreadDispatcher() + val manager = config?.let { + createManager(application, internalConfig, it, cache, logger, mainDispatcher) + } + return QRemoteConfigSnapshotsImpl(manager, bundledReader, mainDispatcher) + } + + @Suppress("LongParameterList") + private fun createManager( + application: Application, + internalConfig: InternalConfig, + config: QRemoteConfigV2Config, + cache: Cache, + logger: Logger, + mainDispatcher: RemoteConfigMainDispatcher, + ): RemoteConfigV2Manager { + val moshi = Moshi.Builder().build() + val primaryConfig = internalConfig.primaryConfig + val store = PersistentRemoteConfigSnapshotStore(cache, moshi) + val core = RemoteConfigSnapshotCore(store, bundledRelease(application, primaryConfig.projectKey)) + // One single-threaded worker for BOTH the preloader and the manager: the manager's + // ordering contract (preload installs before a binding change observes the scope) is + // exactly this executor's FIFO ordering. + val worker = Executors.newSingleThreadExecutor(daemonThreadFactory(REMOTE_CONFIG_V2_WORKER_THREAD_NAME)) + val scheduler = scheduler() + val readGuard = RemoteConfigReadGuard( + core = core, + preloader = PersistentRemoteConfigReadPreloader(store, worker), + buildMode = if (application.isDebuggable) { + RemoteConfigReadBuildMode.Debug + } else { + RemoteConfigReadBuildMode.Release + }, + assertion = { message -> + logger.error(message) + // Only fires when JVM assertions are enabled, so a debug build shouts without + // turning a config read into a production crash. + assert(false) { message } + }, + telemetry = { event -> logger.debug("Remote Config v2 guard event: $event") }, + ) + val scopeHolder = RemoteConfigV2ScopeHolder() + val clock = RemoteConfigFetchClock { System.currentTimeMillis() } + val coordinator = RemoteConfigFetchCoordinator( + core = core, + transport = transport(application, internalConfig, config, scopeHolder, cache, moshi, logger, clock), + policyStore = PersistentRemoteConfigFetchPolicyStore(cache, moshi), + clock = clock, + random = { Random.Default.nextDouble() }, + scheduler = scheduler, + policy = RemoteConfigFetchPolicy( + minimumFetchIntervalMillis = REMOTE_CONFIG_V2_MINIMUM_FETCH_INTERVAL_MILLIS, + // A backstop above the per-call waits: it releases waiters that joined a request + // the socket timeouts somehow outlived, so one wedged call cannot park later ones. + timeoutMillis = REMOTE_CONFIG_V2_REQUEST_TIMEOUT_MILLIS, + ), + ) + return RemoteConfigV2Manager( + core = core, + readGuard = readGuard, + coordinator = coordinator, + options = RemoteConfigV2Options( + projectKey = primaryConfig.projectKey, + environmentUid = config.environmentUid, + projectId = config.projectId, + contextFingerprint = config.contextFingerprint, + ), + scopeHolder = scopeHolder, + scheduler = scheduler, + worker = worker, + mainDispatcher = mainDispatcher, + logger = logger, + ) + } + + @Suppress("LongParameterList") + private fun transport( + application: Application, + internalConfig: InternalConfig, + config: QRemoteConfigV2Config, + scopeHolder: RemoteConfigV2ScopeHolder, + cache: Cache, + moshi: Moshi, + logger: Logger, + clock: RemoteConfigFetchClock, + ) = RemoteConfigGatewayTransport( + // A dedicated client: the shared one carries the legacy NetworkInterceptor, which would + // append a second Authorization header to requests this transport signs itself. + callFactory = OkHttpClient.Builder() + .connectTimeout(REMOTE_CONFIG_V2_TRANSPORT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(REMOTE_CONFIG_V2_TRANSPORT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(REMOTE_CONFIG_V2_TRANSPORT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build(), + baseUrlProvider = { config.baseUrl }, + identityProvider = { + scopeHolder.scope?.let { scope -> + RemoteConfigTransportIdentity( + scope = scope, + projectToken = internalConfig.primaryConfig.projectKey, + // Read from the scope, not from the live uid: they are the same value by + // construction, and reading one source makes it impossible to mint a session + // for one identity and admit its snapshot into another identity's store. + userUid = scope.canonicalUserId, + ) + } + }, + clientContextProvider = DeviceRemoteConfigClientContextProvider( + context = application, + sdkVersion = internalConfig.primaryConfig.sdkVersion, + ), + sessionStore = PersistentRemoteConfigSessionStore(cache, moshi), + clock = clock, + moshi = moshi, + logger = logger, + ) + + private fun bundledRelease(application: Application, projectKey: String): RemoteConfigScopedBundledRelease? = try { + BundledRemoteConfigDefaultsReader().read(application)?.toScopedRemoteConfigSnapshotRelease(projectKey) + } catch (@Suppress("TooGenericExceptionCaught") _: RuntimeException) { + // A malformed bundle degrades the ladder to "no fallback", never to a broken SDK. + null + } + + private fun scheduler(): RemoteConfigFetchScheduler { + val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor( + daemonThreadFactory(REMOTE_CONFIG_V2_SCHEDULER_THREAD_NAME), + ) + return RemoteConfigFetchScheduler { delayMillis, action -> + val future = executor.schedule(action, delayMillis, TimeUnit.MILLISECONDS) + RemoteConfigFetchScheduledTask { future.cancel(false) } + } + } + + private fun daemonThreadFactory(name: String) = ThreadFactory { runnable -> + Thread(runnable, name).apply { isDaemon = true } + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Manager.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Manager.kt new file mode 100644 index 000000000..46aa75670 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Manager.kt @@ -0,0 +1,323 @@ +@file:OptIn(ExperimentalQonversionApi::class) + +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigActivationResult +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchResult +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchStatus +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSnapshot +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSubscription +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigUpdate +import com.qonversion.android.sdk.internal.logger.Logger +import java.util.concurrent.Executor +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +internal const val REMOTE_CONFIG_V2_DEFAULT_FETCH_TIMEOUT_MILLIS = 5_000L + +/** + * Immutable addressing of one Remote Config v2 integration. + * + * [contextFingerprint] is the server-resolved targeting-context binding an admitted snapshot must + * carry. The SDK cannot compute it (it is keyed server-side), so it is supplied by configuration + * until the gateway hands it over on session bootstrap. + */ +internal data class RemoteConfigV2Options( + val projectKey: String, + val environmentUid: String, + val projectId: Long, + val contextFingerprint: String, +) + +/** + * The identity scope the transport addresses, published for the transport's identity provider. + * + * The transport is constructed before any identity is known, so it reads the scope through this + * holder instead of capturing one. + */ +internal class RemoteConfigV2ScopeHolder { + private val current = AtomicReference(null) + + var scope: RemoteConfigSnapshotScope? + get() = current.get() + set(value) = current.set(value) +} + +internal fun interface RemoteConfigMainDispatcher { + /** Runs [action] on the main thread, inline when the caller is already on it. */ + fun post(action: () -> Unit) + + /** + * Runs [action] on the main thread, never inline. + * + * Used for app-supplied listeners: a snapshot activation can be committed *from* the main + * thread (the read guard's implicit activation), and running a listener inline there would + * execute app code while the core still holds its delivery-drain ownership — a listener that + * touches another Qonversion API from there can deadlock against an identity transition. + */ + fun postDeferred(action: () -> Unit) = post(action) +} + +/** + * Binds the Remote Config v2 internals — snapshot core, read guard, fetch coordinator and gateway + * transport — into the operations the public [com.qonversion.android.sdk.QRemoteConfigSnapshots] surface + * exposes. + * + * Threading contract: + * - every completion is delivered exactly once through [mainDispatcher]; + * - every operation that can touch durable storage runs on [worker], which MUST be the same + * single-threaded executor the read guard's preloader uses. That ordering is what keeps a scope + * transition from racing its own preload: the preload task is enqueued first and therefore + * installs the loaded state before the coordinator's binding change observes the scope. + */ +@Suppress("LongParameterList") +internal class RemoteConfigV2Manager( + private val core: RemoteConfigSnapshotCore, + private val readGuard: RemoteConfigReadGuard, + private val coordinator: RemoteConfigFetchCoordinator, + private val options: RemoteConfigV2Options, + private val scopeHolder: RemoteConfigV2ScopeHolder, + private val scheduler: RemoteConfigFetchScheduler, + private val worker: Executor, + private val mainDispatcher: RemoteConfigMainDispatcher, + private val logger: Logger, + private val defaultFetchTimeoutMillis: Long = REMOTE_CONFIG_V2_DEFAULT_FETCH_TIMEOUT_MILLIS, +) { + /** + * Switches the served scope to [canonicalUserId] and kicks off a forced fetch. + * + * The scope swap is performed synchronously on the calling thread, so the previous identity's + * snapshot stops being readable before this call returns — a read that races an identity + * change can only ever see the new (initially fallback-only) scope, never the old release. + */ + fun updateIdentity(canonicalUserId: String, forceReason: RemoteConfigFetchForceReason) { + val scope = scopeFor(canonicalUserId) + // Order matters: the core stops accepting admissions for the previous scope BEFORE the + // transport starts addressing the new one. The reverse order leaves a window in which a + // concurrent fetch reads the new identity and admits its snapshot into the old store. + readGuard.transitionScopeBeforeSdkReady(scope) + scopeHolder.scope = scope + val binding = scope?.let { RemoteConfigFetchBinding(it, expectation()) } + val submitted = submit { + coordinator.transitionTo(binding) + if (binding != null) forceFetch(forceReason) + } + if (!submitted) logger.debug("Remote Config v2 could not apply an identity change") + } + + /** + * Re-reads targeting for the *same* identity, e.g. after user properties or an attached + * experiment changed the evaluation inputs. + * + * Deliberately not a scope transition: the identity did not change, so the served release must + * keep serving until a newer one is fetched and activated. + */ + fun refreshTargeting() { + if (scopeHolder.scope == null) return + submit { forceFetch(RemoteConfigFetchForceReason.Identify) } + } + + private fun forceFetch(forceReason: RemoteConfigFetchForceReason) { + // No caller is waiting, so no timeout is armed — the request runs to its own completion. + fetch(timeoutMillis = 0, forceReason = forceReason) { result -> + if (result.status != QRemoteConfigFetchStatus.Fetched && + result.status != QRemoteConfigFetchStatus.NotModified + ) { + logger.debug("Remote Config v2 forced fetch ended as ${result.status}") + } + } + } + + val current: QRemoteConfigSnapshot get() = QRemoteConfigSnapshot(readGuard.currentSnapshot()) + + /** + * Fetches a release and completes with the best available data. + * + * [timeoutMillis] bounds the *wait*, not the request: when it elapses the completion fires with + * [QRemoteConfigFetchStatus.TimedOut] and the request keeps running, so a slow response is + * still admitted and offered to the next activation. + */ + fun fetch( + timeoutMillis: Long?, + forceReason: RemoteConfigFetchForceReason? = null, + callback: (QRemoteConfigFetchResult) -> Unit, + ) { + val delivery = SingleDelivery(callback) + val timeoutTask = scheduleTimeout(timeoutMillis, delivery) + val submitted = submit { + coordinator.fetch(forceReason) { result -> + timeoutTask.cancelSafely() + delivery.deliver(result.toPublicResult()) + } + } + if (!submitted) { + timeoutTask.cancelSafely() + delivery.deliver(result(QRemoteConfigFetchStatus.Failed)) + } + } + + /** Swaps the last fetched release into [current] atomically, on the worker thread. */ + fun activate(fetchStatus: QRemoteConfigFetchStatus? = null, callback: (QRemoteConfigActivationResult) -> Unit) { + val delivery = SingleDelivery(callback) + val submitted = submit { + val transition = try { + readGuard.activate() + } catch (@Suppress("TooGenericExceptionCaught") error: RuntimeException) { + logger.debug("Remote Config v2 activation failed: ${error.javaClass.simpleName}") + RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.Ignored) + } + if (transition.status == RemoteConfigSnapshotTransitionStatus.PersistenceFailed) { + // The app keeps serving the previously activated release; say so, because + // `changed = false` alone is indistinguishable from "there was nothing new". + logger.error("Remote Config v2 activation could not be persisted") + } + val changed = transition.status == RemoteConfigSnapshotTransitionStatus.Activated && transition.changed + delivery.deliver( + QRemoteConfigActivationResult( + changed = changed, + snapshot = QRemoteConfigSnapshot(core.currentSnapshot()), + fetchStatus = fetchStatus, + ), + ) + } + if (!submitted) { + delivery.deliver( + QRemoteConfigActivationResult( + changed = false, + snapshot = QRemoteConfigSnapshot(core.currentSnapshot()), + fetchStatus = fetchStatus, + ), + ) + } + } + + fun fetchAndActivate(timeoutMillis: Long?, callback: (QRemoteConfigActivationResult) -> Unit) { + fetch(timeoutMillis) { fetchResult -> + activate(fetchResult.status, callback) + } + } + + fun subscribeOnConfigUpdate(listener: (QRemoteConfigUpdate) -> Unit): QRemoteConfigSubscription { + val token = core.addUpdateObserver { update -> + mainDispatcher.postDeferred { listener(QRemoteConfigUpdate(update)) } + } + return QRemoteConfigSubscription { core.removeUpdateObserver(token) } + } + + private fun scopeFor(canonicalUserId: String): RemoteConfigSnapshotScope? = try { + RemoteConfigSnapshotScope( + projectKey = options.projectKey, + environment = options.environmentUid, + canonicalUserId = canonicalUserId, + ) + } catch (_: IllegalArgumentException) { + logger.debug("Remote Config v2 identity is not addressable") + null + } + + private fun expectation() = RemoteConfigSnapshotEnvelopeExpectation( + projectId = options.projectId, + environmentUid = options.environmentUid, + contextFingerprint = options.contextFingerprint, + ) + + private fun scheduleTimeout( + timeoutMillis: Long?, + delivery: SingleDelivery, + ): RemoteConfigFetchScheduledTask? { + val effective = (timeoutMillis ?: defaultFetchTimeoutMillis).takeIf { it > 0 } ?: return null + return try { + scheduler.schedule(effective) { + delivery.deliver(result(QRemoteConfigFetchStatus.TimedOut)) + } + } catch (@Suppress("TooGenericExceptionCaught") _: RuntimeException) { + null + } + } + + /** + * The snapshot a completion reports: freshly fetched when there is one, otherwise the + * activated release, otherwise the bundled defaults. + * + * It deliberately reads the core rather than the read guard — a completion is an explicit + * hand-off of data the app asked for, not an implicit `current` read, so it must not consume + * the guard's one-shot read-before-activate opportunity. + */ + private fun bestAvailableSnapshot(): QRemoteConfigSnapshot = + QRemoteConfigSnapshot(core.lastFetchedSnapshot() ?: core.currentSnapshot()) + + private fun result(status: QRemoteConfigFetchStatus) = + QRemoteConfigFetchResult(status, bestAvailableSnapshot()) + + private fun RemoteConfigFetchResult.toPublicResult(): QRemoteConfigFetchResult = when (this) { + is RemoteConfigFetchResult.Fetched -> result(transition.toFetchStatus()) + RemoteConfigFetchResult.NotModified -> result(QRemoteConfigFetchStatus.NotModified) + is RemoteConfigFetchResult.Failed -> result(QRemoteConfigFetchStatus.Failed) + is RemoteConfigFetchResult.MinimumInterval -> result(QRemoteConfigFetchStatus.Throttled) + is RemoteConfigFetchResult.Backoff -> result(QRemoteConfigFetchStatus.Throttled) + // The coordinator's backstop timeout reports the activated snapshot only; the public + // contract promises the fetched -> cache -> fallback ladder on every completion. + is RemoteConfigFetchResult.TimedOut -> result(QRemoteConfigFetchStatus.TimedOut) + // The release was admitted (or refused) exactly as any other outcome; only the fetch + // bookkeeping could not be persisted, which the next attempt re-derives. + is RemoteConfigFetchResult.PolicyPersistenceFailed -> result.toPublicResult() + RemoteConfigFetchResult.InvalidNotModified -> result(QRemoteConfigFetchStatus.Failed) + RemoteConfigFetchResult.Superseded -> result(QRemoteConfigFetchStatus.Superseded) + } + + private fun RemoteConfigSnapshotTransitionResult.toFetchStatus(): QRemoteConfigFetchStatus = when (status) { + // Rejected covers a malformed envelope AND a snapshot whose project id, environment or + // context fingerprint does not match the configured expectation. The latter is a + // permanent misconfiguration that otherwise looks exactly like a network failure. + RemoteConfigSnapshotTransitionStatus.Accepted, + RemoteConfigSnapshotTransitionStatus.Activated, + RemoteConfigSnapshotTransitionStatus.Unchanged, + -> QRemoteConfigFetchStatus.Fetched + RemoteConfigSnapshotTransitionStatus.Ignored -> QRemoteConfigFetchStatus.Superseded + RemoteConfigSnapshotTransitionStatus.PersistenceFailed -> QRemoteConfigFetchStatus.Failed + RemoteConfigSnapshotTransitionStatus.Rejected -> { + logger.error( + "Remote Config v2 refused a snapshot: it did not match the configured project id, " + + "environment uid or context fingerprint, or the envelope was malformed", + ) + QRemoteConfigFetchStatus.Failed + } + } + + private fun submit(action: () -> Unit): Boolean = try { + worker.execute { + try { + action() + } catch (@Suppress("TooGenericExceptionCaught") error: RuntimeException) { + logger.debug("Remote Config v2 background work failed: ${error.javaClass.simpleName}") + } + } + true + } catch (@Suppress("TooGenericExceptionCaught") _: RuntimeException) { + false + } + + private fun RemoteConfigFetchScheduledTask?.cancelSafely() { + try { + this?.cancel() + } catch (@Suppress("TooGenericExceptionCaught") _: RuntimeException) { + // Single-delivery is enforced independently of best-effort timer cancellation. + } + } + + private inner class SingleDelivery(private val callback: (T) -> Unit) { + private val delivered = AtomicBoolean(false) + + fun deliver(value: T) { + if (!delivered.compareAndSet(false, true)) return + mainDispatcher.post { + try { + callback(value) + } catch (@Suppress("TooGenericExceptionCaught") error: RuntimeException) { + logger.debug("Remote Config v2 callback threw: ${error.javaClass.simpleName}") + } + } + } + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaults.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaults.kt index eb14aac5b..cb2ece45a 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaults.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/services/BundledRemoteConfigDefaults.kt @@ -272,6 +272,18 @@ private fun parsePortableJson(bytes: ByteArray): ParsedJson? = try { internal fun isPortableRemoteConfigJson(bytes: ByteArray): Boolean = parsePortableJson(bytes) != null +/** + * Holds one decoded portable JSON value. + * + * The wrapper exists so a valid JSON `null` stays distinguishable from "these bytes are not + * portable JSON": both would otherwise be a bare `null`, and the snapshot resolution ladder reads + * a `null` decode as "reject this value and try the next ladder position". + */ +internal class PortableRemoteConfigJson(val value: Any?) + +internal fun decodePortableRemoteConfigJson(bytes: ByteArray): PortableRemoteConfigJson? = + parsePortableJson(bytes)?.let { parsed -> PortableRemoteConfigJson(parsed.value) } + @Suppress("ComplexMethod") private fun JsonReader.readPortableJsonValue(depth: Int): Any? = when (peek()) { JsonReader.Token.BEGIN_ARRAY -> { diff --git a/sdk/src/main/java/com/qonversion/android/sdk/listeners/QRemoteConfigUpdateListener.kt b/sdk/src/main/java/com/qonversion/android/sdk/listeners/QRemoteConfigUpdateListener.kt new file mode 100644 index 000000000..3504fabc2 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/listeners/QRemoteConfigUpdateListener.kt @@ -0,0 +1,15 @@ +package com.qonversion.android.sdk.listeners + +import com.qonversion.android.sdk.ExperimentalQonversionApi +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigUpdate + +/** + * Notified whenever a Remote Config release becomes current. + * + * Delivered on the main thread, after the swap is committed, so reading + * `QRemoteConfigSnapshots.current` from the callback already observes the new release. + */ +@ExperimentalQonversionApi +fun interface QRemoteConfigUpdateListener { + fun onRemoteConfigUpdated(update: QRemoteConfigUpdate) +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/listeners/QonversionRemoteConfigActivationCallback.kt b/sdk/src/main/java/com/qonversion/android/sdk/listeners/QonversionRemoteConfigActivationCallback.kt new file mode 100644 index 000000000..1582af8c4 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/listeners/QonversionRemoteConfigActivationCallback.kt @@ -0,0 +1,12 @@ +package com.qonversion.android.sdk.listeners + +import com.qonversion.android.sdk.ExperimentalQonversionApi +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigActivationResult + +/** + * Called exactly once, on the main thread, when a Remote Config activation completes. + */ +@ExperimentalQonversionApi +fun interface QonversionRemoteConfigActivationCallback { + fun onResult(result: QRemoteConfigActivationResult) +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/listeners/QonversionRemoteConfigFetchCallback.kt b/sdk/src/main/java/com/qonversion/android/sdk/listeners/QonversionRemoteConfigFetchCallback.kt new file mode 100644 index 000000000..6f6c14aa8 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/listeners/QonversionRemoteConfigFetchCallback.kt @@ -0,0 +1,12 @@ +package com.qonversion.android.sdk.listeners + +import com.qonversion.android.sdk.ExperimentalQonversionApi +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchResult + +/** + * Called exactly once, on the main thread, when a Remote Config fetch completes or times out. + */ +@ExperimentalQonversionApi +fun interface QonversionRemoteConfigFetchCallback { + fun onResult(result: QRemoteConfigFetchResult) +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2ConfigTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2ConfigTest.kt new file mode 100644 index 000000000..fefb267ee --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2ConfigTest.kt @@ -0,0 +1,60 @@ +@file:OptIn(ExperimentalQonversionApi::class) + +package com.qonversion.android.sdk.dto.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +/** + * The v2 configuration is rejected at construction rather than at `build()`: it carries values the + * app cannot invent, so failing at the line that supplies them is what makes the mistake findable. + */ +internal class QRemoteConfigV2ConfigTest { + + @Test + fun `a well formed configuration is accepted verbatim`() { + val config = QRemoteConfigV2Config( + baseUrl = "https://gateway.example.com/", + environmentUid = "production", + projectId = 42, + contextFingerprint = FINGERPRINT, + ) + + assertEquals("https://gateway.example.com/", config.baseUrl) + assertEquals("production", config.environmentUid) + assertEquals(42L, config.projectId) + assertEquals(FINGERPRINT, config.contextFingerprint) + } + + @Test + fun `every malformed field is rejected`() { + val malformed = listOf QRemoteConfigV2Config>>( + "relative base url" to { config(baseUrl = "gateway.example.com") }, + "scheme-less base url" to { config(baseUrl = "//gateway.example.com") }, + "empty environment" to { config(environmentUid = "") }, + "over-long environment" to { config(environmentUid = "e".repeat(37)) }, + "zero project id" to { config(projectId = 0) }, + "negative project id" to { config(projectId = -1) }, + "uppercase fingerprint" to { config(contextFingerprint = FINGERPRINT.uppercase()) }, + "short fingerprint" to { config(contextFingerprint = "a".repeat(63)) }, + "non-hex fingerprint" to { config(contextFingerprint = "z".repeat(64)) }, + ) + + malformed.forEach { (name, build) -> + assertThrows(name, IllegalArgumentException::class.java) { build() } + } + } + + private fun config( + baseUrl: String = "https://gateway.example.com/", + environmentUid: String = "production", + projectId: Long = 42, + contextFingerprint: String = FINGERPRINT, + ) = QRemoteConfigV2Config(baseUrl, environmentUid, projectId, contextFingerprint) + + private companion object { + const val FINGERPRINT = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigsPublicApiTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigsPublicApiTest.kt new file mode 100644 index 000000000..ac328fc13 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigsPublicApiTest.kt @@ -0,0 +1,554 @@ +@file:OptIn(ExperimentalQonversionApi::class) + +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigActivationResult +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigApplyPolicy +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigDecoder +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchResult +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchStatus +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSource +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigUpdate +import com.qonversion.android.sdk.listeners.QonversionRemoteConfigActivationCallback +import com.qonversion.android.sdk.listeners.QonversionRemoteConfigFetchCallback +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * Contract tests for the public Remote Config v2 surface, driven end to end: real snapshot core, + * real read guard, real fetch coordinator, real HTTP. + */ +internal class QRemoteConfigsPublicApiTest { + private val harnesses = mutableListOf() + + @After + fun tearDown() { + harnesses.forEach { it.shutdown() } + } + + @Test + fun `fetch times out with the best available snapshot while the request keeps running`() { + val harness = harness() + harness.delaySnapshotReads(RESPONSE_DELAY_MILLIS) + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + + val latch = CountDownLatch(1) + val result = AtomicReference() + harness.manager.fetch(FETCH_TIMEOUT_MILLIS) { fetchResult -> + result.set(fetchResult) + latch.countDown() + } + awaitScheduledTimeout(harness) + + assertTrue("timeout was not delivered", latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(listOf(FETCH_TIMEOUT_MILLIS), harness.timeoutScheduler.requestedDelays) + val timedOut = requireNotNull(result.get()) + assertEquals(QRemoteConfigFetchStatus.TimedOut, timedOut.status) + // Best available at timeout time: nothing was admitted yet, so the ladder is at fallback. + val fallback = requireNotNull(timedOut.snapshot.rawValue("count")) + assertEquals(QRemoteConfigSource.Fallback, fallback.source) + assertEquals("0", fallback.value) + + // The request was not cancelled — its release is still admitted and activates normally. + harness.awaitCandidate(releaseNumber = 1) + assertTrue(harness.activateBlocking().changed) + assertEquals("1", harness.configs.current.rawValue("count")?.value) + } + + @Test + fun `fetch without an explicit timeout uses the configured default`() { + val harness = harness(defaultFetchTimeoutMillis = FETCH_TIMEOUT_MILLIS) + harness.hangSnapshotReads(true) + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + + val latch = CountDownLatch(1) + val result = AtomicReference() + harness.manager.fetch(null) { fetchResult -> + result.set(fetchResult) + latch.countDown() + } + awaitScheduledTimeout(harness) + + assertTrue(latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(QRemoteConfigFetchStatus.TimedOut, requireNotNull(result.get()).status) + // The default is what was scheduled — a hard-coded or ignored timeout would show up here. + assertEquals(listOf(FETCH_TIMEOUT_MILLIS), harness.timeoutScheduler.requestedDelays) + } + + @Test + fun `activate reports a change only when the activated release differs`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + + assertEquals(QRemoteConfigFetchStatus.Fetched, harness.fetchBlocking().status) + val first = harness.activateBlocking() + val second = harness.activateBlocking() + + assertTrue("the first activation must be a change", first.changed) + assertFalse("re-activating the same release changes nothing", second.changed) + assertEquals("release-1", first.snapshot.releaseUid) + assertNull(first.fetchStatus) + } + + @Test + fun `fetchAndActivate carries the fetch status into the activation result`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + + val latch = CountDownLatch(1) + val result = AtomicReference() + // Through the public facade: this overload must not delegate to itself. + harness.configs.fetchAndActivate { activation -> + result.set(activation) + latch.countDown() + } + + assertTrue(latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + val activation = requireNotNull(result.get()) + assertEquals(QRemoteConfigFetchStatus.Fetched, activation.fetchStatus) + assertTrue(activation.changed) + assertEquals("1", activation.snapshot.rawValue("count")?.value) + } + + @Test + fun `every public fetch and activate overload completes exactly once`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + + val fetched = awaitFetch(harness) { callback -> harness.configs.fetch(callback) } + assertEquals(QRemoteConfigFetchStatus.Fetched, fetched.status) + val fetchedWithTimeout = awaitFetch(harness) { callback -> + harness.configs.fetch(TIMEOUT_UNUSED, callback) + } + assertEquals(QRemoteConfigFetchStatus.Fetched, fetchedWithTimeout.status) + + val activated = awaitActivation(harness) { callback -> harness.configs.activate(callback) } + assertTrue(activated.changed) + assertNull(activated.fetchStatus) + val reActivated = awaitActivation(harness) { callback -> + harness.configs.fetchAndActivate(TIMEOUT_UNUSED, callback) + } + assertFalse(reActivated.changed) + assertEquals(QRemoteConfigFetchStatus.Fetched, reActivated.fetchStatus) + } + + @Test + fun `a server error is reported as a failed fetch`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.serveStatus(HTTP_SERVER_ERROR) + + assertEquals(QRemoteConfigFetchStatus.Failed, harness.fetchBlocking().status) + } + + @Test + fun `an unchanged release is reported as not modified`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.serveStatus(HTTP_NOT_MODIFIED) + + assertEquals(QRemoteConfigFetchStatus.NotModified, harness.fetchBlocking().status) + } + + @Test + fun `a fetch inside the minimum interval is throttled`() { + val harness = RemoteConfigV2Harness(minimumFetchIntervalMillis = THROTTLE_INTERVAL_MILLIS) + .also { harnesses += it } + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + + assertEquals(QRemoteConfigFetchStatus.Throttled, harness.fetchBlocking().status) + } + + @Test + fun `a snapshot bound to another targeting context is refused`() { + val harness = harness() + harness.serveForeignContextFingerprint() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + + assertEquals(QRemoteConfigFetchStatus.Failed, harness.fetchBlocking().status) + assertNull(harness.core.lastFetchedSnapshot()) + assertEquals(QRemoteConfigSource.Fallback, harness.configs.current.rawValue("count")?.source) + } + + @Test + fun `a key without metadata reports no metadata rather than the JSON literal`() { + val harness = harness() + harness.serve( + "release-1", + 1, + listOf( + RcWireValue("count", "1"), + RcWireValue("annotated", "2", metadata = "{\"reload\":true}"), + ), + ) + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + + val snapshot = harness.configs.current + assertNull(snapshot.rawValue("count")?.metadataJson) + assertEquals("{\"reload\":true}", snapshot.rawValue("annotated")?.metadataJson) + } + + @Test + fun `a decoder that throws rejects the value like one that returns null`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + + val thrown = harness.configs.current.value("count") { error("decoder blew up") } + + // No prior release to fall back to and no bundled value the decoder accepts, so the read + // resolves to nothing instead of propagating the failure to the caller. + assertNull(thrown) + } + + @Test + fun `reads report every ladder position with its value`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + + val server = requireNotNull(harness.configs.current.value("count", INT_DECODER)) + assertEquals(QRemoteConfigSource.Server, server.source) + assertEquals(1, server.value) + + val fallback = requireNotNull(harness.configs.current.value("bundled_only", STRING_DECODER)) + assertEquals(QRemoteConfigSource.Fallback, fallback.source) + assertEquals("\"bundled\"", fallback.value) + + // A release whose value the caller's decoder rejects falls back to the previously + // activated one — that is the cache position, and only a typed read can observe it. + harness.serve("release-2", 2, listOf(RcWireValue("count", "\"not-a-number\""))) + harness.fetchBlocking() + harness.activateBlocking() + + val cached = requireNotNull(harness.configs.current.value("count", INT_DECODER)) + assertEquals(QRemoteConfigSource.Cache, cached.source) + assertEquals(1, cached.value) + } + + @Test + fun `raw reads stay opaque while typed reads apply the decoder`() { + val harness = harness() + harness.serve("release-1", 1, listOf(RcWireValue("count", "{\"nested\":[1,2]}"))) + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + + val snapshot = harness.configs.current + val raw = requireNotNull(snapshot.rawValue("count")) + assertEquals("{\"nested\":[1,2]}", raw.value) + assertEquals(QRemoteConfigSource.Server, raw.source) + assertEquals(QRemoteConfigApplyPolicy.OnNextActivate, raw.applyPolicy) + assertEquals("var-count-on_next_activate", raw.variationUid) + + val json = requireNotNull(snapshot.jsonValue("count")) + @Suppress("UNCHECKED_CAST") + val nested = (json.value as Map)["nested"] as List + assertEquals(listOf(1.0, 2.0), nested) + + val typed = requireNotNull(snapshot.value("count", QRemoteConfigDecoder { rawJson -> rawJson.length })) + assertEquals("{\"nested\":[1,2]}".length, typed.value) + assertNull(snapshot.rawValue("unknown-key")) + assertEquals(setOf("count", "bundled_only"), snapshot.contextKeys) + } + + @Test + fun `bundled fallback values answer before any fetch or activation`() { + val harness = harness() + + // No identity, no fetch, no activation: the bundled getter is a pure asset read. + assertEquals("bundled", harness.configs.fallbackRemoteConfigValue("bundled_only")?.rawValue) + assertEquals(0.0, harness.configs.fallbackRemoteConfigValue("count")?.rawValue) + assertNull(harness.configs.fallbackRemoteConfigValue("unknown-key")) + + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.awaitWorkerIdle() + val preActivate = requireNotNull(harness.configs.current.rawValue("count")) + assertEquals(QRemoteConfigSource.Fallback, preActivate.source) + } + + @Test + fun `reading before activate is reported in a debug build`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.awaitWorkerIdle() + + harness.configs.current + harness.configs.current + + assertEquals(listOf(REMOTE_CONFIG_READ_BEFORE_ACTIVATE_MESSAGE), harness.assertions) + assertTrue(harness.guardEvents.contains(RemoteConfigReadGuardEvent.ReadBeforeActivate)) + } + + @Test + fun `a release build silently activates once on the first read`() { + val harness = harness(buildMode = RemoteConfigReadBuildMode.Release) + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + + val implicitlyActivated = requireNotNull(harness.configs.current.rawValue("count")) + + assertEquals(QRemoteConfigSource.Server, implicitlyActivated.source) + assertEquals("1", implicitlyActivated.value) + assertTrue(harness.assertions.isEmpty()) + assertTrue(harness.guardEvents.contains(RemoteConfigReadGuardEvent.ImplicitActivation)) + } + + @Test + fun `an immediate release activates the whole release and reaches subscribers`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + + val updates = Collections.synchronizedList(mutableListOf()) + val latch = CountDownLatch(1) + harness.subscribeCollecting(updates, latch) + harness.serve( + "release-2", + 2, + listOf( + RcWireValue("count", "5", applyPolicy = "immediate", metadata = "{\"reload\":true}"), + RcWireValue("extra", "\"new\""), + ), + ) + harness.fetchBlocking() + + assertTrue("no update was delivered", latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + val update = updates.single() + assertEquals(setOf("count", "extra"), update.changedKeys) + assertEquals(QRemoteConfigApplyPolicy.Immediate, update.applyPolicy("count")) + assertEquals("{\"reload\":true}", update.metadataJson("count")) + // The whole release was swapped, not just the immediate key. + assertEquals("5", harness.configs.current.rawValue("count")?.value) + assertEquals("\"new\"", harness.configs.current.rawValue("extra")?.value) + assertEquals("release-2", update.snapshot.releaseUid) + } + + @Test + fun `a removed subscription stops receiving updates`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + + val updates = Collections.synchronizedList(mutableListOf()) + val subscription = harness.subscribeCollecting(updates, CountDownLatch(1)) + subscription.remove() + subscription.remove() + + harness.serve("release-2", 2, listOf(RcWireValue("count", "9"))) + harness.fetchBlocking() + harness.activateBlocking() + + assertEquals("9", harness.configs.current.rawValue("count")?.value) + assertTrue(updates.isEmpty()) + } + + @Test + fun `refreshing targeting re-fetches without dropping the served release`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + harness.serve("release-2", 2, listOf(RcWireValue("count", "2"))) + + harness.manager.refreshTargeting() + harness.awaitCandidate(releaseNumber = 2) + + // Same identity: the served release must keep serving until the app activates the new one. + assertEquals("1", harness.configs.current.rawValue("count")?.value) + assertTrue(harness.activateBlocking().changed) + assertEquals("2", harness.configs.current.rawValue("count")?.value) + } + + @Test + fun `an identity switch hides the previous snapshot and forces a fresh fetch`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + assertEquals("1", harness.configs.current.rawValue("count")?.value) + + harness.serve("release-9", 9, listOf(RcWireValue("count", "9"))) + harness.identify("QON_anon_b", "canonical-b", RemoteConfigFetchForceReason.Logout) + + // Decision A: the previous identity's release is unreadable the instant the scope switches, + // without waiting for any background work. + val afterSwitch = requireNotNull(harness.configs.current.rawValue("count")) + assertEquals(QRemoteConfigSource.Fallback, afterSwitch.source) + assertEquals("0", afterSwitch.value) + + harness.awaitCandidate(releaseNumber = 9) + harness.activateBlocking() + assertEquals("9", harness.configs.current.rawValue("count")?.value) + + // The old identity's snapshot is still stored under its own scope and never leaks. + val scopeA = RemoteConfigSnapshotScope(RC_PROJECT_KEY, RC_ENVIRONMENT, "canonical-a") + val scopeB = RemoteConfigSnapshotScope(RC_PROJECT_KEY, RC_ENVIRONMENT, "canonical-b") + assertEquals(1L, harness.snapshotStore.states[scopeA]?.active?.releaseNumber) + assertEquals(9L, harness.snapshotStore.states[scopeB]?.active?.releaseNumber) + // A session is minted per identity: the new uid never replays the previous token. + assertTrue(harness.sessionRequests.size >= 2) + assertTrue(harness.sessionRequests.any { it.contains("QON_anon_a") }) + assertTrue(harness.sessionRequests.any { it.contains("QON_anon_b") }) + } + + @Test + fun `the client context is re-sent for every identity`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.identify("QON_anon_b", "canonical-b", RemoteConfigFetchForceReason.Logout) + awaitSnapshotReads(harness, count = 2) + + val installDates = harness.snapshotRequests.map { body -> + Regex("\"device_installed_at\":(\\d+)").find(body)?.groupValues?.get(1) + } + assertTrue("expected snapshot reads for both identities", installDates.size >= 2) + // That this value is genuinely device-scoped (rather than a constant supplied by this + // harness) is proven against a real PackageManager in RemoteConfigV2DeviceScopeTest. + assertEquals(setOf(RC_DEVICE_INSTALLED_AT.toString()), installDates.toSet()) + } + + @Test + fun `every completion is delivered on the main thread`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + val threads = Collections.synchronizedList(mutableListOf()) + val updates = Collections.synchronizedList(mutableListOf()) + val updateLatch = CountDownLatch(1) + harness.manager.subscribeOnConfigUpdate { update -> + threads += Thread.currentThread().name + updates += update + updateLatch.countDown() + } + + // fetchBlocking / activateBlocking assert the callback thread internally. + harness.fetchBlocking() + harness.activateBlocking() + + assertTrue(updateLatch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(listOf(RC_MAIN_THREAD_NAME), threads) + assertEquals(1, updates.size) + } + + @Test + fun `a dormant configuration answers NotConfigured and still serves bundled defaults`() { + val dormant = QRemoteConfigSnapshotsImpl( + manager = null, + bundledValueReader = { contextKey -> + if (contextKey == "bundled_only") { + com.qonversion.android.sdk.dto.QRemoteConfigFallbackValue("bundled") + } else { + null + } + }, + mainDispatcher = { action -> action() }, + ) + + val fetchResult = AtomicReference() + dormant.fetch { result -> fetchResult.set(result) } + val activationResult = AtomicReference() + dormant.fetchAndActivate(TIMEOUT_UNUSED) { result -> activationResult.set(result) } + + assertEquals(QRemoteConfigFetchStatus.NotConfigured, requireNotNull(fetchResult.get()).status) + assertEquals( + QRemoteConfigFetchStatus.NotConfigured, + requireNotNull(activationResult.get()).fetchStatus, + ) + assertFalse(requireNotNull(activationResult.get()).changed) + assertTrue(dormant.current.contextKeys.isEmpty()) + assertNull(dormant.current.rawValue("count")) + assertEquals("bundled", dormant.fallbackRemoteConfigValue("bundled_only")?.rawValue) + val updates = Collections.synchronizedList(mutableListOf()) + val subscription = dormant.subscribeOnConfigUpdate { update -> updates += update } + subscription.remove() + subscription.remove() + assertTrue(updates.isEmpty()) + } + + private fun awaitFetch( + harness: RemoteConfigV2Harness, + call: (QonversionRemoteConfigFetchCallback) -> Unit, + ): QRemoteConfigFetchResult { + val latch = CountDownLatch(1) + val results = Collections.synchronizedList(mutableListOf()) + call( + QonversionRemoteConfigFetchCallback { result -> + results += result + latch.countDown() + }, + ) + assertTrue(latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(1, results.size) + return results.single() + } + + private fun awaitActivation( + harness: RemoteConfigV2Harness, + call: (QonversionRemoteConfigActivationCallback) -> Unit, + ): QRemoteConfigActivationResult { + val latch = CountDownLatch(1) + val results = Collections.synchronizedList(mutableListOf()) + call( + QonversionRemoteConfigActivationCallback { result -> + results += result + latch.countDown() + }, + ) + assertTrue(latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(1, results.size) + return results.single() + } + + private fun harness( + buildMode: RemoteConfigReadBuildMode = RemoteConfigReadBuildMode.Debug, + defaultFetchTimeoutMillis: Long = 0, + ) = RemoteConfigV2Harness( + buildMode = buildMode, + defaultFetchTimeoutMillis = defaultFetchTimeoutMillis, + ).also { harnesses += it } + + private fun awaitSnapshotReads(harness: RemoteConfigV2Harness, count: Int) { + val deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(RC_AWAIT_SECONDS) + while (harness.snapshotRequests.size < count && System.currentTimeMillis() < deadline) { + Thread.sleep(POLL_INTERVAL_MILLIS) + } + } + + private fun awaitScheduledTimeout(harness: RemoteConfigV2Harness) { + val deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(RC_AWAIT_SECONDS) + while (harness.timeoutScheduler.pendingCount() == 0 && System.currentTimeMillis() < deadline) { + Thread.sleep(POLL_INTERVAL_MILLIS) + } + harness.timeoutScheduler.runAll() + } + + private companion object { + const val FETCH_TIMEOUT_MILLIS = 50L + const val RESPONSE_DELAY_MILLIS = 2_000L + const val POLL_INTERVAL_MILLIS = 10L + const val TIMEOUT_UNUSED = 5_000L + const val THROTTLE_INTERVAL_MILLIS = 600_000L + + val INT_DECODER = QRemoteConfigDecoder { rawJson -> rawJson.toIntOrNull() } + val STRING_DECODER = QRemoteConfigDecoder { rawJson -> rawJson } + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinatorTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinatorTest.kt index e891c009a..8d56dc958 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinatorTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinatorTest.kt @@ -400,13 +400,19 @@ internal class RemoteConfigFetchCoordinatorTest { responseThread.start() assertTrue(parserStarted.await(2, TimeUnit.SECONDS)) val transitionFinished = CountDownLatch(1) + val transitionEntered = CountDownLatch(1) val transitionThread = Thread { + transitionEntered.countDown() coordinator.transitionTo(null) events += "transition" transitionFinished.countDown() } transitionThread.start() + // Wait for the thread to actually be running before timing it: without this the + // "did not finish in 100 ms" check also passes when the thread was never scheduled, + // which turns the ordering assertion below into a race on a loaded machine. + assertTrue(transitionEntered.await(2, TimeUnit.SECONDS)) assertFalse(transitionFinished.await(100, TimeUnit.MILLISECONDS)) releaseParser.countDown() responseThread.join(2_000) diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2DeviceScopeTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2DeviceScopeTest.kt new file mode 100644 index 000000000..1b8049611 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2DeviceScopeTest.kt @@ -0,0 +1,70 @@ +@file:OptIn(ExperimentalQonversionApi::class) + +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import java.util.concurrent.TimeUnit + +private const val FIRST_INSTALL_TIME_MILLIS = 1_577_836_800_123L +private const val EXPECTED_INSTALLED_AT_SECONDS = 1_577_836_800L +private const val POLL_INTERVAL_MILLIS = 10L + +/** + * Proves the device-scoped part of the client context end to end, through the public fetch path. + * + * The provider under test is the real one, reading a real (Robolectric) `PackageManager`: an + * identity switch must not move `device_installed_at`, because the server evaluates account age as + * `min(device_installed_at, client.created_at)` and a post-logout client row is always brand new. + */ +@RunWith(RobolectricTestRunner::class) +internal class RemoteConfigV2DeviceScopeTest { + private var harness: RemoteConfigV2Harness? = null + + @After + fun tearDown() { + harness?.shutdown() + } + + @Test + fun `device_installed_at is identical across an identity switch`() { + val application = RuntimeEnvironment.getApplication() + shadowOf(application.packageManager) + .getInternalMutablePackageInfo(application.packageName) + .apply { + firstInstallTime = FIRST_INSTALL_TIME_MILLIS + versionName = "1.2.3" + } + val started = RemoteConfigV2Harness( + clientContextProvider = DeviceRemoteConfigClientContextProvider(application, "9.7.0"), + ).also { harness = it } + + started.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + started.fetchBlocking() + started.identify("QON_anon_b", "canonical-b", RemoteConfigFetchForceReason.Logout) + awaitSnapshotReads(started, count = 2) + + val installedAt = started.snapshotRequests.map { body -> + Regex("\"device_installed_at\":(\\d+)").find(body)?.groupValues?.get(1) + } + assertTrue("expected a snapshot read per identity", installedAt.size >= 2) + // Without this the test would also pass if the identity switch had silently no-op'd. + assertTrue(started.sessionRequests.any { it.contains("QON_anon_a") }) + assertTrue(started.sessionRequests.any { it.contains("QON_anon_b") }) + assertEquals(setOf(EXPECTED_INSTALLED_AT_SECONDS.toString()), installedAt.toSet()) + } + + private fun awaitSnapshotReads(harness: RemoteConfigV2Harness, count: Int) { + val deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(RC_AWAIT_SECONDS) + while (harness.snapshotRequests.size < count && System.currentTimeMillis() < deadline) { + Thread.sleep(POLL_INTERVAL_MILLIS) + } + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt new file mode 100644 index 000000000..ac3bafc58 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt @@ -0,0 +1,439 @@ +@file:OptIn(ExperimentalQonversionApi::class) + +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi +import com.qonversion.android.sdk.QRemoteConfigSnapshots +import com.qonversion.android.sdk.dto.QRemoteConfigFallbackValue +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigActivationResult +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchResult +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigUpdate +import com.qonversion.android.sdk.internal.logger.Logger +import com.qonversion.android.sdk.internal.services.decodePortableRemoteConfigJson +import com.qonversion.android.sdk.internal.storage.RemoteConfigSnapshotLoadResult +import com.qonversion.android.sdk.internal.storage.RemoteConfigSnapshotLoadStatus +import com.qonversion.android.sdk.internal.storage.RemoteConfigSnapshotStore +import com.squareup.moshi.Moshi +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okio.Buffer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import java.security.MessageDigest +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +internal const val RC_PROJECT_KEY = "project-key" +internal const val RC_ENVIRONMENT = "production" +internal const val RC_PROJECT_ID = 42L +internal const val RC_FINGERPRINT_LENGTH = 64 +internal const val RC_AWAIT_SECONDS = 10L +internal const val RC_MAIN_THREAD_NAME = "qonversion-test-main" +internal const val RC_SESSION_PATH = "/v3/remote-config-v2/session" +internal const val RC_SNAPSHOT_PATH = "/v3/remote-config-v2/snapshot" +internal const val RC_DEVICE_INSTALLED_AT = 1_577_836_800L +internal const val HTTP_OK = 200 +internal const val HTTP_NOT_MODIFIED = 304 +internal const val HTTP_SERVER_ERROR = 500 + +internal val RC_FINGERPRINT = "a".repeat(RC_FINGERPRINT_LENGTH) + +/** One value of a scripted snapshot release. */ +internal data class RcWireValue( + val key: String, + val raw: String, + val applyPolicy: String = "on_next_activate", + val metadata: String = "null", +) { + fun toJson(): String = "\"$key\":{\"raw\":$raw,\"variation_uid\":\"var-$key-$applyPolicy\"," + + "\"apply_policy\":\"$applyPolicy\",\"metadata\":$metadata}" +} + +internal fun rcWireBody( + releaseUid: String, + releaseNumber: Long, + values: List, + contextFingerprint: String = RC_FINGERPRINT, +): String = "{\"schema_version\":1,\"project_id\":$RC_PROJECT_ID," + + "\"environment_uid\":\"$RC_ENVIRONMENT\",\"release_uid\":\"$releaseUid\"," + + "\"release_number\":$releaseNumber,\"manifest_content_hash\":\"${"1".repeat(RC_FINGERPRINT_LENGTH)}\"," + + "\"complete_key_set\":true,\"context_fingerprint\":\"$contextFingerprint\"," + + "\"values\":{${values.joinToString(",") { it.toJson() }}}}" + +internal fun rcStrongETag(body: ByteArray): String = MessageDigest.getInstance("SHA-256") + .digest(body) + .joinToString(prefix = "\"", postfix = "\"", separator = "") { byte -> "%02x".format(byte) } + +/** + * The Remote Config defaults "bundled with the app" for these tests. + * + * `count` is also served by every scripted release, so it can be observed at all three ladder + * positions; `bundled_only` exists nowhere else, so it can only ever resolve to the fallback. + */ +internal fun rcBundledRelease() = RemoteConfigScopedBundledRelease( + projectKey = RC_PROJECT_KEY, + environment = RC_ENVIRONMENT, + release = RemoteConfigSnapshotRelease( + releaseUid = "bundled-release", + releaseNumber = 1, + manifestContentHash = "2".repeat(RC_FINGERPRINT_LENGTH), + entries = listOf( + RemoteConfigSnapshotEntry.value( + key = "count", + rawValue = "0".encodeToByteArray(), + variationUid = "bundled-count", + applyPolicy = RemoteConfigSnapshotApplyPolicy.OnNextActivate, + metadata = null, + ), + RemoteConfigSnapshotEntry.value( + key = "bundled_only", + rawValue = "\"bundled\"".encodeToByteArray(), + variationUid = "bundled-only", + applyPolicy = RemoteConfigSnapshotApplyPolicy.OnNextActivate, + metadata = null, + ), + ), + ), +) + +/** + * Builds the real chain behind the public API — snapshot core, read guard, fetch coordinator and + * the gateway transport over a real [MockWebServer] — so the tests exercise the shipped wiring + * rather than a mock of it. + * + * Only three things are test doubles, each for determinism rather than convenience: the snapshot / + * policy stores are in memory, the fetch timeout scheduler is manual, and the "main thread" is a + * single named executor so callback threading can be asserted. + */ +@Suppress("LongParameterList") +internal class RemoteConfigV2Harness( + buildMode: RemoteConfigReadBuildMode = RemoteConfigReadBuildMode.Debug, + bundled: RemoteConfigScopedBundledRelease? = rcBundledRelease(), + defaultFetchTimeoutMillis: Long = 0, + minimumFetchIntervalMillis: Long = 0, + clientContextProvider: RemoteConfigClientContextProvider = RemoteConfigClientContextProvider { + RemoteConfigClientContext( + platform = "android", + appVersion = "1.2.3", + osVersion = "14", + sdkVersion = "9.7.0", + locale = "en_US", + deviceModel = "Pixel 8", + deviceInstalledAtSeconds = RC_DEVICE_INSTALLED_AT, + ) + }, +) { + private val bundledEntries = bundled?.release + private val httpClient = OkHttpClient() + val server = MockWebServer() + val snapshotStore = InMemorySnapshotStore() + val timeoutScheduler = ManualScheduler() + val assertions: MutableList = Collections.synchronizedList(mutableListOf()) + val guardEvents: MutableList = Collections.synchronizedList(mutableListOf()) + val snapshotRequests: MutableList = Collections.synchronizedList(mutableListOf()) + val sessionRequests: MutableList = Collections.synchronizedList(mutableListOf()) + + @Volatile + var userUid: String = "QON_anon_a" + + private val body = AtomicReference(defaultBody()) + private val hang = AtomicReference(false) + private val responseDelayMillis = AtomicReference(0L) + private val snapshotStatusCode = AtomicReference(HTTP_OK) + private val contextFingerprint = AtomicReference(RC_FINGERPRINT) + private val worker: ExecutorService = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "qonversion-test-worker") + } + private val mainExecutor: ExecutorService = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, RC_MAIN_THREAD_NAME) + } + private val scopeHolder = RemoteConfigV2ScopeHolder() + private val mainDispatcher = RemoteConfigMainDispatcher { action -> mainExecutor.execute(action) } + + val core = RemoteConfigSnapshotCore(snapshotStore, bundled) + + private val readGuard = RemoteConfigReadGuard( + core = core, + preloader = PersistentRemoteConfigReadPreloader(snapshotStore, worker), + buildMode = buildMode, + assertion = { message -> assertions += message }, + telemetry = { event -> guardEvents += event }, + ) + + val coordinator = RemoteConfigFetchCoordinator( + core = core, + transport = RemoteConfigGatewayTransport( + callFactory = httpClient, + baseUrlProvider = { server.url("/").toString() }, + identityProvider = { + scopeHolder.scope?.let { scope -> + RemoteConfigTransportIdentity(scope, "project-token", userUid) + } + }, + clientContextProvider = clientContextProvider, + sessionStore = InMemorySessionStore(), + clock = { System.currentTimeMillis() }, + moshi = Moshi.Builder().build(), + logger = SilentLogger(), + ), + policyStore = InMemoryFetchPolicyStore(), + clock = { System.currentTimeMillis() }, + random = { 0.5 }, + // The coordinator's own timeout is disabled: the public API's per-call timeout is the + // behaviour under test, and a second timer would make which one fired ambiguous. + scheduler = { _, _ -> RemoteConfigFetchScheduledTask { } }, + policy = RemoteConfigFetchPolicy( + minimumFetchIntervalMillis = minimumFetchIntervalMillis, + timeoutMillis = null, + ), + ) + + val manager = RemoteConfigV2Manager( + core = core, + readGuard = readGuard, + coordinator = coordinator, + options = RemoteConfigV2Options(RC_PROJECT_KEY, RC_ENVIRONMENT, RC_PROJECT_ID, RC_FINGERPRINT), + scopeHolder = scopeHolder, + scheduler = timeoutScheduler, + worker = worker, + mainDispatcher = mainDispatcher, + logger = SilentLogger(), + defaultFetchTimeoutMillis = defaultFetchTimeoutMillis, + ) + + val configs: QRemoteConfigSnapshots = QRemoteConfigSnapshotsImpl( + manager = manager, + bundledValueReader = { contextKey -> fallbackRemoteConfigValue(contextKey) }, + mainDispatcher = mainDispatcher, + ) + + init { + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = when (request.path) { + RC_SESSION_PATH -> { + sessionRequests += request.body.readUtf8() + sessionResponse() + } + RC_SNAPSHOT_PATH -> { + snapshotRequests += request.body.readUtf8() + snapshotResponse() + } + else -> MockResponse().setResponseCode(404) + } + } + server.start() + } + + fun shutdown() { + server.shutdown() + worker.shutdownNow() + mainExecutor.shutdownNow() + httpClient.dispatcher().executorService().shutdownNow() + httpClient.connectionPool().evictAll() + } + + /** Scripts the release the gateway serves from now on. */ + fun serve(releaseUid: String, releaseNumber: Long, values: List) { + body.set(rcWireBody(releaseUid, releaseNumber, values, contextFingerprint.get())) + } + + /** Makes the gateway answer snapshot reads with [statusCode] instead of a release. */ + fun serveStatus(statusCode: Int) = snapshotStatusCode.set(statusCode) + + /** Serves releases bound to a different targeting context than the one the SDK expects. */ + fun serveForeignContextFingerprint() { + contextFingerprint.set("b".repeat(RC_FINGERPRINT_LENGTH)) + body.set(defaultBody(contextFingerprint.get())) + } + + /** Makes the gateway stop answering snapshot reads, without closing the socket. */ + fun hangSnapshotReads(hanging: Boolean) = hang.set(hanging) + + /** Delays the snapshot answer, so a caller-side timeout can win the race deterministically. */ + fun delaySnapshotReads(millis: Long) = responseDelayMillis.set(millis) + + fun identify(userUid: String, canonicalUserId: String, reason: RemoteConfigFetchForceReason) { + this.userUid = userUid + manager.updateIdentity(canonicalUserId, reason) + } + + fun fetchBlocking(timeoutMs: Long? = null): QRemoteConfigFetchResult { + val latch = CountDownLatch(1) + val result = AtomicReference() + val thread = AtomicReference() + manager.fetch(timeoutMs) { fetchResult -> + thread.set(Thread.currentThread().name) + result.set(fetchResult) + latch.countDown() + } + assertTrue("fetch did not complete", latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(RC_MAIN_THREAD_NAME, thread.get()) + return requireNotNull(result.get()) + } + + fun activateBlocking(): QRemoteConfigActivationResult { + val latch = CountDownLatch(1) + val result = AtomicReference() + val thread = AtomicReference() + manager.activate { activationResult -> + thread.set(Thread.currentThread().name) + result.set(activationResult) + latch.countDown() + } + assertTrue("activation did not complete", latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(RC_MAIN_THREAD_NAME, thread.get()) + return requireNotNull(result.get()) + } + + fun subscribeCollecting(updates: MutableList, latch: CountDownLatch) = + manager.subscribeOnConfigUpdate { update -> + updates += update + latch.countDown() + } + + /** Waits until [releaseNumber] is durably admitted as the fetched candidate. */ + fun awaitCandidate(releaseNumber: Long) { + val deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(RC_AWAIT_SECONDS) + while (System.currentTimeMillis() < deadline) { + if (core.lastFetchedSnapshot()?.releaseNumber == releaseNumber) return + Thread.sleep(POLL_INTERVAL_MILLIS) + } + throw AssertionError("release $releaseNumber was never admitted") + } + + /** Blocks until every task already queued on the background worker has run. */ + fun awaitWorkerIdle() { + val latch = CountDownLatch(1) + worker.execute { latch.countDown() } + assertTrue("worker did not drain", latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + } + + /** + * Reads the same bundled entries the snapshot core resolves against, so the "manual fallback + * getter" and the fallback rung of the ladder can never silently drift apart. + */ + private fun fallbackRemoteConfigValue(contextKey: String): QRemoteConfigFallbackValue? { + val raw = bundledEntries?.entry(contextKey)?.rawValueBytes ?: return null + return QRemoteConfigFallbackValue( + requireNotNull(decodePortableRemoteConfigJson(raw)).value, + ) + } + + private fun defaultBody(fingerprint: String = RC_FINGERPRINT) = + rcWireBody("release-1", 1, listOf(RcWireValue("count", "1")), fingerprint) + + private fun sessionResponse() = MockResponse() + .setResponseCode(200) + .setBody( + "{\"session_token\":\"qrcs1.session-${sessionRequests.size}\",\"project_id\":$RC_PROJECT_ID," + + "\"environment\":\"prod\",\"expires_at\":\"2030-01-01T00:00:00Z\"}", + ) + + private fun snapshotResponse(): MockResponse { + if (hang.get()) return MockResponse().setSocketPolicy(okhttp3.mockwebserver.SocketPolicy.NO_RESPONSE) + val statusCode = snapshotStatusCode.get() + if (statusCode != HTTP_OK) { + val response = MockResponse().setResponseCode(statusCode) + return if (statusCode == HTTP_NOT_MODIFIED) { + response.setHeader("ETag", rcStrongETag(body.get().toByteArray(Charsets.UTF_8))) + } else { + response + } + } + val bytes = body.get().toByteArray(Charsets.UTF_8) + return MockResponse() + .setResponseCode(200) + .setHeader("ETag", rcStrongETag(bytes)) + .setBody(Buffer().write(bytes)) + .setBodyDelay(responseDelayMillis.get(), TimeUnit.MILLISECONDS) + } + + private companion object { + const val POLL_INTERVAL_MILLIS = 20L + } +} + +internal class ManualScheduler : RemoteConfigFetchScheduler { + private val tasks = mutableListOf() + + /** Every delay the code under test asked for, in scheduling order. */ + val requestedDelays: MutableList = Collections.synchronizedList(mutableListOf()) + + override fun schedule(delayMillis: Long, action: () -> Unit): RemoteConfigFetchScheduledTask { + val task = Task(action) + synchronized(tasks) { tasks += task } + requestedDelays += delayMillis + return RemoteConfigFetchScheduledTask { task.cancelled = true } + } + + /** Fires every scheduled task that has not been cancelled yet. */ + fun runAll() { + val pending = synchronized(tasks) { tasks.toList().also { tasks.clear() } } + pending.forEach { task -> if (!task.cancelled) task.action() } + } + + fun pendingCount(): Int = synchronized(tasks) { tasks.count { !it.cancelled } } + + private class Task(val action: () -> Unit, @Volatile var cancelled: Boolean = false) +} + +internal class InMemorySnapshotStore : RemoteConfigSnapshotStore { + val states = mutableMapOf() + + @Synchronized + override fun load(scope: RemoteConfigSnapshotScope): RemoteConfigSnapshotLoadResult = + states[scope]?.let { RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Found, it) } + ?: RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Missing) + + @Synchronized + override fun save(scope: RemoteConfigSnapshotScope, state: RemoteConfigSnapshotState): Boolean { + states[scope] = state + return true + } +} + +internal class InMemoryFetchPolicyStore : RemoteConfigFetchPolicyStore { + private val states = mutableMapOf() + + @Synchronized + override fun load(scope: RemoteConfigFetchPolicyScope) = states[scope] + + @Synchronized + override fun save(scope: RemoteConfigFetchPolicyScope, state: RemoteConfigFetchPolicyState): Boolean { + states[scope] = state + return true + } +} + +internal class InMemorySessionStore : RemoteConfigSessionStore { + private val sessions = mutableMapOf() + + @Synchronized + override fun load(key: RemoteConfigSessionKey) = sessions[key] + + @Synchronized + override fun save(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession): Boolean { + sessions[key] = session + return true + } + + @Synchronized + override fun clear(key: RemoteConfigSessionKey): Boolean { + sessions.remove(key) + return true + } +} + +internal class SilentLogger : Logger { + override fun error(message: String) = Unit + override fun warn(message: String) = Unit + override fun release(message: String) = Unit + override fun debug(message: String) = Unit +} From 11ec810ff4c3cf8768bafb04e61717217130c940 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Fri, 7 Aug 2026 14:15:32 +0300 Subject: [PATCH 2/3] feat(remote-config): stop requiring a v2 context fingerprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QRemoteConfigV2Config demanded a `contextFingerprint`: the app had to hand the SDK the fingerprint the gateway resolves for it, and an admitted snapshot had to carry exactly that value. Nobody could supply it correctly, because it is not an app-level constant at all. configurator computes it in BuildResolvedSnapshotContextFingerprint (internal/domain/remoteconfigv2/resolved_snapshot.go) by hashing the canonical user uid, randomization id, platform, country, app version, OS version, SDK version, locale, device model, media source / campaign, install and created timestamps, purchases, active experiment uids and custom user properties. It is a per-response tag over mutable targeting inputs, not an identity binding: it rotates on any app or OS update, a language switch, a purchase, a property edit or an experiment enrollment. So the fingerprint is treated as what it is: - it is gone from the public QRemoteConfigV2Config and from the internal RemoteConfigSnapshotEnvelopeExpectation — nothing configures it and nothing compares it against a previous response; - the parser keeps validating its *shape* (64 lowercase hex, required member) and carries it through as an opaque per-response tag; a value that changes between two admissions in the same scope is normal and admitted; - it is still stored with the release, as informational data for logs and bug reports, and it still participates in the release content digest; - the KDoc on the public config, the expectation and the release all state the rule verbatim, so the "pin it across fetches" idea does not get re-invented: pinning it would freeze an identity's config until logout the first time the user updated the app or changed their language. Identity isolation is unchanged and stays where it already lives: each snapshot read travels on a session token minted for exactly one identity, the gateway routes on that session, and the snapshot / session / fetch-policy stores address each identity through its own salted scope digest. No compatibility shim: the surface is @ExperimentalQonversionApi and has never shipped as stable, so the constructor parameter is simply removed. Tests: a rotated fingerprint is admitted end to end through the public API over MockWebServer (and at the core, where the previously admitted release keeps its own tag), every malformed or missing fingerprint is still refused by the parser, and the project / environment admission boundaries are unchanged. Claude-Session: https://claude.ai/code/session_018pHXqfbxkMQJFzUZ3jW4A8 --- .../dto/remoteconfig/QRemoteConfigV2Config.kt | 16 +++---- .../remoteconfig/RemoteConfigSnapshot.kt | 9 ++++ .../RemoteConfigSnapshotEnvelopeParser.kt | 19 +++++--- .../remoteconfig/RemoteConfigV2Factory.kt | 1 - .../remoteconfig/RemoteConfigV2Manager.kt | 20 ++++----- .../remoteconfig/QRemoteConfigV2ConfigTest.kt | 12 +----- .../QRemoteConfigsPublicApiTest.kt | 19 +++++--- .../RemoteConfigFetchCoordinatorTest.kt | 10 ++--- ...teConfigGatewayTransportCoordinatorTest.kt | 1 - .../RemoteConfigSnapshotCoreTest.kt | 43 +++++++++---------- .../RemoteConfigSnapshotEnvelopeParserTest.kt | 30 +++++++++++-- .../remoteconfig/RemoteConfigV2TestHarness.kt | 19 ++++++-- ...PersistentRemoteConfigSnapshotStoreTest.kt | 2 - 13 files changed, 119 insertions(+), 82 deletions(-) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2Config.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2Config.kt index 20fac0712..97fad62c6 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2Config.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2Config.kt @@ -3,7 +3,6 @@ package com.qonversion.android.sdk.dto.remoteconfig import com.qonversion.android.sdk.ExperimentalQonversionApi private const val REMOTE_CONFIG_V2_UID_MAX_CODE_POINTS = 36 -private val LOWERCASE_SHA256_PATTERN = Regex("^[0-9a-f]{64}$") /** * Enables the experimental Remote Config v2 snapshot pipeline. @@ -14,14 +13,17 @@ private val LOWERCASE_SHA256_PATTERN = Regex("^[0-9a-f]{64}$") * [QRemoteConfigFetchStatus.NotConfigured] while still serving bundled defaults. There is no * default base URL and no production endpoint is contacted implicitly. * + * The targeting context a snapshot was resolved for is deliberately **not** configured here, and no + * future version will ask for it. The fingerprint hashes mutable targeting context (app/OS version, + * locale, purchases, properties); it rotates legitimately and MUST NOT be pinned across fetches. + * Identity isolation is the session's job: every snapshot read travels on a session token minted + * for exactly one identity, the gateway routes on that session, and the SDK stores each identity's + * releases under its own scoped storage key. + * * @param baseUrl base URL of the Remote Config v2 gateway, e.g. `https://host/`. The SDK appends * its own paths, so a bare origin is expected. * @param environmentUid uid of the Remote Config environment to read. * @param projectId numeric project id the served snapshots must belong to. - * @param contextFingerprint the snapshot context fingerprint the gateway resolves for this - * integration. It binds an admitted snapshot to the targeting context it was resolved for, and the - * SDK cannot derive it — the value is server-side keyed. It is a temporary integration hand-off: - * once the gateway returns the fingerprint on session bootstrap, this parameter goes away. * @throws IllegalArgumentException if any value is malformed. */ @ExperimentalQonversionApi @@ -29,7 +31,6 @@ class QRemoteConfigV2Config( val baseUrl: String, val environmentUid: String, val projectId: Long, - val contextFingerprint: String, ) { init { require(baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { @@ -40,8 +41,5 @@ class QRemoteConfigV2Config( environmentUid.codePointCount(0, environmentUid.length) <= REMOTE_CONFIG_V2_UID_MAX_CODE_POINTS, ) { "Remote Config v2 environment uid must be 1..$REMOTE_CONFIG_V2_UID_MAX_CODE_POINTS code points" } require(projectId > 0) { "Remote Config v2 project id must be positive" } - require(LOWERCASE_SHA256_PATTERN.matches(contextFingerprint)) { - "Remote Config v2 context fingerprint must be 64 lowercase hexadecimal characters" - } } } diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshot.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshot.kt index 0b251a053..2bf78b904 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshot.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshot.kt @@ -117,6 +117,15 @@ internal class RemoteConfigSnapshotEntry private constructor( } } +/** + * One admitted Remote Config release. + * + * [contextFingerprint] is **informational**: it records which targeting context the gateway resolved + * this response for, which is useful in a bug report or a log line. It is not an admission input. + * The fingerprint hashes mutable targeting context (app/OS version, locale, purchases, properties); + * it rotates legitimately and MUST NOT be pinned across fetches. Identity isolation is the session's + * job. + */ internal class RemoteConfigSnapshotRelease( val releaseUid: String, val releaseNumber: Long, diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParser.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParser.kt index b1e43fe3c..e05c97f1f 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParser.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParser.kt @@ -22,10 +22,20 @@ private val PORTABLE_JSON_MAX_INTEGER_BIG = BigInteger.valueOf(PORTABLE_JSON_MAX private val PORTABLE_JSON_MIN_INTEGER_BIG = PORTABLE_JSON_MAX_INTEGER_BIG.negate() private val LOWERCASE_SHA256_PATTERN = Regex("^[0-9a-f]{64}$") +/** + * The addressing an envelope must match to be admitted: exactly the project and environment the SDK + * was configured for. + * + * The targeting context is deliberately absent. The fingerprint hashes mutable targeting context + * (app/OS version, locale, purchases, properties); it rotates legitimately and MUST NOT be pinned + * across fetches. Identity isolation is the session's job — the snapshot read travels on a session + * token minted for one identity and the gateway routes on it — so the parser validates the + * fingerprint's *shape* and carries it through as an opaque per-response tag, and nothing anywhere + * compares it against a previous response's value. + */ internal data class RemoteConfigSnapshotEnvelopeExpectation( val projectId: Long, val environmentUid: String, - val contextFingerprint: String, ) internal class RemoteConfigSnapshotEnvelope internal constructor( @@ -58,8 +68,7 @@ internal class RemoteConfigSnapshotEnvelopeParser : RemoteConfigSnapshotEnvelope if (!expectation.isValid()) return null return parseBoundBody(body, etag)?.takeIf { envelope -> envelope.projectId == expectation.projectId && - envelope.environmentUid == expectation.environmentUid && - envelope.contextFingerprint == expectation.contextFingerprint + envelope.environmentUid == expectation.environmentUid } } @@ -475,9 +484,7 @@ private class SnapshotJsonReader(private val bytes: ByteArray) { } private fun RemoteConfigSnapshotEnvelopeExpectation.isValid(): Boolean = - projectId in 1..PORTABLE_JSON_MAX_INTEGER && - environmentUid.isValidUid() && - LOWERCASE_SHA256_PATTERN.matches(contextFingerprint) + projectId in 1..PORTABLE_JSON_MAX_INTEGER && environmentUid.isValidUid() private fun String.isValidUid(): Boolean = isNotEmpty() && hasValidSurrogatePairs() && codePointCount(0, length) <= REMOTE_CONFIG_SNAPSHOT_UID_MAX_CODE_POINTS diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt index ca5dfd844..07fe65ceb 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt @@ -116,7 +116,6 @@ internal object RemoteConfigV2Factory { projectKey = primaryConfig.projectKey, environmentUid = config.environmentUid, projectId = config.projectId, - contextFingerprint = config.contextFingerprint, ), scopeHolder = scopeHolder, scheduler = scheduler, diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Manager.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Manager.kt index 46aa75670..684ed1223 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Manager.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Manager.kt @@ -19,15 +19,15 @@ internal const val REMOTE_CONFIG_V2_DEFAULT_FETCH_TIMEOUT_MILLIS = 5_000L /** * Immutable addressing of one Remote Config v2 integration. * - * [contextFingerprint] is the server-resolved targeting-context binding an admitted snapshot must - * carry. The SDK cannot compute it (it is keyed server-side), so it is supplied by configuration - * until the gateway hands it over on session bootstrap. + * The server-resolved targeting context is deliberately not part of it. The fingerprint hashes + * mutable targeting context (app/OS version, locale, purchases, properties); it rotates legitimately + * and MUST NOT be pinned across fetches. Identity isolation is the session's job — see + * [RemoteConfigGatewaySession] and the per-scope storage keys. */ internal data class RemoteConfigV2Options( val projectKey: String, val environmentUid: String, val projectId: Long, - val contextFingerprint: String, ) /** @@ -219,7 +219,6 @@ internal class RemoteConfigV2Manager( private fun expectation() = RemoteConfigSnapshotEnvelopeExpectation( projectId = options.projectId, environmentUid = options.environmentUid, - contextFingerprint = options.contextFingerprint, ) private fun scheduleTimeout( @@ -267,9 +266,10 @@ internal class RemoteConfigV2Manager( } private fun RemoteConfigSnapshotTransitionResult.toFetchStatus(): QRemoteConfigFetchStatus = when (status) { - // Rejected covers a malformed envelope AND a snapshot whose project id, environment or - // context fingerprint does not match the configured expectation. The latter is a - // permanent misconfiguration that otherwise looks exactly like a network failure. + // Rejected covers a malformed envelope AND a snapshot whose project id or environment does + // not match the configured expectation. The latter is a permanent misconfiguration that + // otherwise looks exactly like a network failure. A changed targeting context is NOT in + // this class: it rotates on any app/OS update, locale change, purchase or property edit. RemoteConfigSnapshotTransitionStatus.Accepted, RemoteConfigSnapshotTransitionStatus.Activated, RemoteConfigSnapshotTransitionStatus.Unchanged, @@ -278,8 +278,8 @@ internal class RemoteConfigV2Manager( RemoteConfigSnapshotTransitionStatus.PersistenceFailed -> QRemoteConfigFetchStatus.Failed RemoteConfigSnapshotTransitionStatus.Rejected -> { logger.error( - "Remote Config v2 refused a snapshot: it did not match the configured project id, " + - "environment uid or context fingerprint, or the envelope was malformed", + "Remote Config v2 refused a snapshot: it did not match the configured project id " + + "or environment uid, or the envelope was malformed", ) QRemoteConfigFetchStatus.Failed } diff --git a/sdk/src/test/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2ConfigTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2ConfigTest.kt index fefb267ee..b4421d3e3 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2ConfigTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2ConfigTest.kt @@ -19,13 +19,11 @@ internal class QRemoteConfigV2ConfigTest { baseUrl = "https://gateway.example.com/", environmentUid = "production", projectId = 42, - contextFingerprint = FINGERPRINT, ) assertEquals("https://gateway.example.com/", config.baseUrl) assertEquals("production", config.environmentUid) assertEquals(42L, config.projectId) - assertEquals(FINGERPRINT, config.contextFingerprint) } @Test @@ -37,9 +35,6 @@ internal class QRemoteConfigV2ConfigTest { "over-long environment" to { config(environmentUid = "e".repeat(37)) }, "zero project id" to { config(projectId = 0) }, "negative project id" to { config(projectId = -1) }, - "uppercase fingerprint" to { config(contextFingerprint = FINGERPRINT.uppercase()) }, - "short fingerprint" to { config(contextFingerprint = "a".repeat(63)) }, - "non-hex fingerprint" to { config(contextFingerprint = "z".repeat(64)) }, ) malformed.forEach { (name, build) -> @@ -51,10 +46,5 @@ internal class QRemoteConfigV2ConfigTest { baseUrl: String = "https://gateway.example.com/", environmentUid: String = "production", projectId: Long = 42, - contextFingerprint: String = FINGERPRINT, - ) = QRemoteConfigV2Config(baseUrl, environmentUid, projectId, contextFingerprint) - - private companion object { - const val FINGERPRINT = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - } + ) = QRemoteConfigV2Config(baseUrl, environmentUid, projectId) } diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigsPublicApiTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigsPublicApiTest.kt index ac328fc13..72760a9f5 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigsPublicApiTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigsPublicApiTest.kt @@ -172,14 +172,23 @@ internal class QRemoteConfigsPublicApiTest { } @Test - fun `a snapshot bound to another targeting context is refused`() { + fun `a rotated targeting context keeps being served, it is not an identity signal`() { + // The gateway recomputes the fingerprint from mutable inputs — app/OS version, locale, + // purchases, properties, experiment enrollment — so it changes for the same identity all + // the time. Refusing the new value would freeze this user's config until logout. val harness = harness() - harness.serveForeignContextFingerprint() harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + assertEquals(QRemoteConfigFetchStatus.Fetched, harness.fetchBlocking().status) + harness.activateBlocking() - assertEquals(QRemoteConfigFetchStatus.Failed, harness.fetchBlocking().status) - assertNull(harness.core.lastFetchedSnapshot()) - assertEquals(QRemoteConfigSource.Fallback, harness.configs.current.rawValue("count")?.source) + harness.rotateContextFingerprint() + + assertEquals(QRemoteConfigFetchStatus.Fetched, harness.fetchBlocking().status) + harness.activateBlocking() + val served = requireNotNull(harness.configs.current.rawValue("count")) + assertEquals(QRemoteConfigSource.Server, served.source) + assertEquals("2", served.value) + assertEquals("release-rotated", harness.core.lastFetchedSnapshot()?.releaseUid) } @Test diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinatorTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinatorTest.kt index 8d56dc958..d6eaf3626 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinatorTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinatorTest.kt @@ -20,7 +20,6 @@ internal class RemoteConfigFetchCoordinatorTest { expectation = RemoteConfigSnapshotEnvelopeExpectation( projectId = 42, environmentUid = "production", - contextFingerprint = "a".repeat(64), ), ) @@ -114,10 +113,7 @@ internal class RemoteConfigFetchCoordinatorTest { transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 429, retryAfterMillis = 4_000)) coordinator.transitionTo( - binding.copy( - scope = RemoteConfigSnapshotScope("project", "production", "identified-user"), - expectation = binding.expectation.copy(contextFingerprint = "b".repeat(64)), - ), + binding.copy(scope = RemoteConfigSnapshotScope("project", "production", "identified-user")), ) val result = mutableListOf() coordinator.fetch(forceReason = RemoteConfigFetchForceReason.Identify, callback = result::add) @@ -345,7 +341,7 @@ internal class RemoteConfigFetchCoordinatorTest { val nextBinding = binding.copy( scope = RemoteConfigSnapshotScope("project", "production", "canonical-user-next"), - expectation = binding.expectation.copy(contextFingerprint = "b".repeat(64)), + expectation = binding.expectation.copy(projectId = 43), ) coordinator.transitionTo(nextBinding) assertEquals(listOf(RemoteConfigFetchResult.Superseded), oldResults) @@ -354,7 +350,7 @@ internal class RemoteConfigFetchCoordinatorTest { val nextResults = mutableListOf() coordinator.fetch(forceReason = RemoteConfigFetchForceReason.Identify, callback = nextResults::add) - transport.complete(success("wrong-context", 1)) + transport.complete(success("wrong-project", 1)) val transition = (nextResults.single() as RemoteConfigFetchResult.Fetched).transition assertEquals(RemoteConfigSnapshotTransitionStatus.Rejected, transition.status) assertEquals(null, core.lastFetchedSnapshot()) diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportCoordinatorTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportCoordinatorTest.kt index 7156bbca5..a6ce9d8c5 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportCoordinatorTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportCoordinatorTest.kt @@ -249,7 +249,6 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { expectation = RemoteConfigSnapshotEnvelopeExpectation( projectId = 42, environmentUid = "production", - contextFingerprint = "a".repeat(64), ), ) val WIRE_BODY = "{\"schema_version\":1,\"project_id\":42,\"environment_uid\":\"production\"," + diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCoreTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCoreTest.kt index 6b0e0c55c..e67c84df2 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCoreTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCoreTest.kt @@ -431,19 +431,17 @@ internal class RemoteConfigSnapshotCoreTest { } @Test - fun `wire admission fences exact identity project environment and context scope`() { + fun `wire admission fences exact identity project and environment`() { core.setScope(scopeA) val body = wireBody("wire", 1, "\"a\":${wireItem("1")}").encodeToByteArray() assertNull(core.beginAdmission(scopeB, wireExpectation())) assertNull(core.beginAdmission(scopeA, wireExpectation().copy(environmentUid = "staging"))) - for (expectation in listOf( - wireExpectation().copy(projectId = 43), - wireExpectation().copy(contextFingerprint = "b".repeat(64)), - )) { - val token = requireNotNull(core.beginAdmission(scopeA, expectation)) - val result = core.admitCandidate(token, body, strongETag(body)) - assertEquals(RemoteConfigSnapshotTransitionStatus.Rejected, result.status) - } + val token = requireNotNull(core.beginAdmission(scopeA, wireExpectation().copy(projectId = 43))) + + assertEquals( + RemoteConfigSnapshotTransitionStatus.Rejected, + core.admitCandidate(token, body, strongETag(body)).status, + ) assertTrue(store.savedStates.isEmpty()) } @@ -523,15 +521,14 @@ internal class RemoteConfigSnapshotCoreTest { ) assertTrue(secondStore.savedStates.isEmpty()) - val swappedContextBody = wireBody( - "wire-b", - 7, - "\"a\":${wireItem("2")}", - contextFingerprint = "b".repeat(64), - ).encodeToByteArray() + // The expectation travels with the token: one issued for another project cannot admit this + // project's body, even from the core that issued it and on the scope it was issued for. + val foreignProjectToken = requireNotNull( + firstCore.beginAdmission(scopeA, wireExpectation().copy(projectId = 43)), + ) assertEquals( RemoteConfigSnapshotTransitionStatus.Rejected, - firstCore.admitCandidate(token, swappedContextBody, strongETag(swappedContextBody)).status, + firstCore.admitCandidate(foreignProjectToken, validBody, strongETag(validBody)).status, ) assertTrue(firstStore.savedStates.isEmpty()) } @@ -680,7 +677,11 @@ internal class RemoteConfigSnapshotCoreTest { } @Test - fun `same server release can be sequentially admitted for different contexts`() { + fun `a rotated targeting context is admitted, it is an opaque per response tag`() { + // The fingerprint hashes mutable targeting context (app/OS version, locale, purchases, + // properties), so it rotates for reasons that have nothing to do with identity: an app + // update or a language switch changes it. Refusing the new value would freeze this + // identity's config until logout. Identity isolation is the session's job. core.setScope(scopeA) val firstContext = "a".repeat(64) val secondContext = "b".repeat(64) @@ -700,7 +701,7 @@ internal class RemoteConfigSnapshotCoreTest { assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation(firstContext))), + requireNotNull(core.beginAdmission(scopeA, wireExpectation())), first, strongETag(first), ).status, @@ -709,7 +710,7 @@ internal class RemoteConfigSnapshotCoreTest { assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation(secondContext))), + requireNotNull(core.beginAdmission(scopeA, wireExpectation())), second, strongETag(second), ).status, @@ -834,11 +835,9 @@ internal class RemoteConfigSnapshotCoreTest { assertTrue(requireNotNull(saved.active).admissionToken > requireNotNull(saved.previous).admissionToken) } - private fun wireExpectation(contextFingerprint: String = "a".repeat(64)) = - RemoteConfigSnapshotEnvelopeExpectation( + private fun wireExpectation() = RemoteConfigSnapshotEnvelopeExpectation( projectId = 42, environmentUid = "production", - contextFingerprint = contextFingerprint, ) private fun wireBody( diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParserTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParserTest.kt index f3f081af9..b3a6cfbff 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParserTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParserTest.kt @@ -12,7 +12,6 @@ internal class RemoteConfigSnapshotEnvelopeParserTest { private val expectation = RemoteConfigSnapshotEnvelopeExpectation( projectId = 42, environmentUid = "env-production", - contextFingerprint = "a".repeat(64), ) @Test @@ -74,18 +73,36 @@ internal class RemoteConfigSnapshotEnvelopeParserTest { } @Test - fun `expected project environment and context fingerprint are exact admission boundaries`() { + fun `expected project and environment are exact admission boundaries`() { val body = validBody() val mismatches = listOf( expectation.copy(projectId = 43), expectation.copy(environmentUid = "env-staging"), - expectation.copy(contextFingerprint = "b".repeat(64)), ) mismatches.forEach { mismatch -> assertNull(parse(body, mismatch)) } assertNull(parse(body.replace("\"project_id\":42", "\"project_id\":43"))) assertNull(parse(body.replace("env-production", "env-staging"))) - assertNull(parse(body.replace("a".repeat(64), "b".repeat(64)))) + } + + @Test + fun `the targeting context is shape checked and then carried through as an opaque tag`() { + // It hashes mutable targeting context (app/OS version, locale, purchases, properties), so + // it rotates legitimately and is never compared against a previous response's value. + val rotated = parse(withContextFingerprint("b".repeat(64))) + assertEquals("b".repeat(64), rotated?.contextFingerprint) + assertEquals("b".repeat(64), rotated?.release?.contextFingerprint) + + for (malformed in listOf( + "A".repeat(64), + "a".repeat(63), + "a".repeat(65), + "z".repeat(64), + "", + )) { + assertNull(malformed, parse(withContextFingerprint(malformed))) + } + assertNull(parse(validBody().replace("\"context_fingerprint\":\"${"a".repeat(64)}\",", ""))) } @Test @@ -229,6 +246,11 @@ internal class RemoteConfigSnapshotEnvelopeParserTest { return parser.parse(body, strongETag(body), expected) } + private fun withContextFingerprint(fingerprint: String) = validBody().replace( + "\"context_fingerprint\":\"${"a".repeat(64)}\"", + "\"context_fingerprint\":\"$fingerprint\"", + ) + private fun validBody(values: String = "\"only\":${item()}") = "{\"schema_version\":1,\"project_id\":42,\"environment_uid\":\"env-production\"," + "\"release_uid\":\"release\",\"release_number\":7,\"manifest_content_hash\":\"$HASH\"," + diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt index ac3bafc58..1587f30ba 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt @@ -199,7 +199,7 @@ internal class RemoteConfigV2Harness( core = core, readGuard = readGuard, coordinator = coordinator, - options = RemoteConfigV2Options(RC_PROJECT_KEY, RC_ENVIRONMENT, RC_PROJECT_ID, RC_FINGERPRINT), + options = RemoteConfigV2Options(RC_PROJECT_KEY, RC_ENVIRONMENT, RC_PROJECT_ID), scopeHolder = scopeHolder, scheduler = timeoutScheduler, worker = worker, @@ -247,10 +247,20 @@ internal class RemoteConfigV2Harness( /** Makes the gateway answer snapshot reads with [statusCode] instead of a release. */ fun serveStatus(statusCode: Int) = snapshotStatusCode.set(statusCode) - /** Serves releases bound to a different targeting context than the one the SDK expects. */ - fun serveForeignContextFingerprint() { + /** + * Rotates the targeting context the gateway reports, as it does for real when the app version, + * locale, purchases, properties or experiment enrollment change. + */ + fun rotateContextFingerprint() { contextFingerprint.set("b".repeat(RC_FINGERPRINT_LENGTH)) - body.set(defaultBody(contextFingerprint.get())) + body.set( + rcWireBody( + releaseUid = "release-rotated", + releaseNumber = ROTATED_RELEASE_NUMBER, + values = listOf(RcWireValue("count", "2")), + contextFingerprint = contextFingerprint.get(), + ), + ) } /** Makes the gateway stop answering snapshot reads, without closing the socket. */ @@ -357,6 +367,7 @@ internal class RemoteConfigV2Harness( private companion object { const val POLL_INTERVAL_MILLIS = 20L + const val ROTATED_RELEASE_NUMBER = 2L } } diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStoreTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStoreTest.kt index e6746b6a2..b599aee6b 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStoreTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStoreTest.kt @@ -512,7 +512,6 @@ internal class PersistentRemoteConfigSnapshotStoreTest { RemoteConfigSnapshotEnvelopeExpectation( projectId = 42, environmentUid = "production", - contextFingerprint = "b".repeat(64), ), ), ) @@ -694,7 +693,6 @@ internal class PersistentRemoteConfigSnapshotStoreTest { expectation = RemoteConfigSnapshotEnvelopeExpectation( projectId = 42, environmentUid = "production", - contextFingerprint = "b".repeat(64), ), ), ).release From f08a58159129dee0160d01c95d2bfc9a0804cc5b Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Fri, 7 Aug 2026 15:08:53 +0300 Subject: [PATCH 3/3] feat(remote-config)!: learn the v2 project id from the session bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The numeric project id a v2 snapshot is admitted against was supplied by the app through QRemoteConfigV2Config, but the app is not its source: the SDK is told it by the gateway's session bootstrap. Asking for it added a public value that could only ever be typed wrong. BREAKING (experimental surface): QRemoteConfigV2Config no longer takes projectId. Callers drop the argument; nothing else changes for them. The id is now learned and pinned per project key + environment by RemoteConfigProjectIdRegistry, durably, next to the session state: the first bootstrap establishes it, every later session must agree, and one that does not is refused as the typed RemoteConfigFetchResponse .ProjectMismatch before a snapshot is ever read — never re-learned. The in-memory pin is authoritative for the process, so a storage failure cannot downgrade a conflict into a silent re-learn. A malformed id is reported apart from a conflict and stays an ordinary failure. Because the pin is established mid-fetch, the envelope expectation moved from the admission claim to the admission itself: beginAdmission takes only the scope, admitCandidate takes the project id the response was served for, and the environment is read from the admitting scope rather than restated. RemoteConfigFetchBinding was exactly a scope plus that expectation, so it is gone and the coordinator binds to the scope. A mismatch feeds the failure backoff. It is permanent until the gateway is fixed and costs a bootstrap round trip each time, and forced fetches bypass the minimum interval but not the backoff gate, so an identify/logout loop cannot turn a misrouted gateway into a request storm. The check this buys is server-vs-server consistency plus trust on first bootstrap, not proof that a snapshot belongs to the project the developer meant to target; QRemoteConfigV2Config's KDoc says so. --- .../dto/remoteconfig/QRemoteConfigV2Config.kt | 14 +- .../RemoteConfigFetchCoordinator.kt | 65 +++++--- .../RemoteConfigGatewaySession.kt | 121 +++++++++++++++ .../RemoteConfigGatewayTransport.kt | 65 ++++++-- .../remoteconfig/RemoteConfigSnapshotCore.kt | 32 ++-- .../RemoteConfigSnapshotEnvelopeParser.kt | 8 +- .../remoteconfig/RemoteConfigV2Factory.kt | 6 +- .../remoteconfig/RemoteConfigV2Manager.kt | 32 ++-- .../remoteconfig/QRemoteConfigV2ConfigTest.kt | 22 ++- .../RemoteConfigFetchCoordinatorTest.kt | 80 +++++----- ...teConfigGatewayTransportCoordinatorTest.kt | 73 +++++++-- .../RemoteConfigGatewayTransportTest.kt | 116 +++++++++++++- .../RemoteConfigSnapshotCoreTest.kt | 146 +++++++++++------- .../remoteconfig/RemoteConfigV2TestHarness.kt | 18 ++- ...PersistentRemoteConfigSnapshotStoreTest.kt | 10 +- 15 files changed, 603 insertions(+), 205 deletions(-) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2Config.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2Config.kt index 97fad62c6..cb66d29a3 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2Config.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2Config.kt @@ -20,17 +20,26 @@ private const val REMOTE_CONFIG_V2_UID_MAX_CODE_POINTS = 36 * for exactly one identity, the gateway routes on that session, and the SDK stores each identity's * releases under its own scoped storage key. * + * The numeric project id is deliberately **not** configured here either, although a served snapshot + * is checked against one. Unlike the fingerprint it is stable, but the app is not its source: the + * SDK learns it from the gateway's session bootstrap, pins the first value it is ever told, and + * treats a later bootstrap that answers with a different one as a hard failure. + * + * That is a trade, not a strict improvement: the check no longer proves a snapshot belongs to the + * project the developer meant to target — the first bootstrap is trusted — it proves that every + * snapshot and every later session agree with the first one. What it buys is that a value the app + * could only ever get wrong is gone, and the property that actually protects a user — a snapshot + * being served for the session that asked for it — is enforced against the server's own answer. + * * @param baseUrl base URL of the Remote Config v2 gateway, e.g. `https://host/`. The SDK appends * its own paths, so a bare origin is expected. * @param environmentUid uid of the Remote Config environment to read. - * @param projectId numeric project id the served snapshots must belong to. * @throws IllegalArgumentException if any value is malformed. */ @ExperimentalQonversionApi class QRemoteConfigV2Config( val baseUrl: String, val environmentUid: String, - val projectId: Long, ) { init { require(baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { @@ -40,6 +49,5 @@ class QRemoteConfigV2Config( environmentUid.isNotEmpty() && environmentUid.codePointCount(0, environmentUid.length) <= REMOTE_CONFIG_V2_UID_MAX_CODE_POINTS, ) { "Remote Config v2 environment uid must be 1..$REMOTE_CONFIG_V2_UID_MAX_CODE_POINTS code points" } - require(projectId > 0) { "Remote Config v2 project id must be positive" } } } diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinator.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinator.kt index 034e40924..dd7d1e7c4 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinator.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinator.kt @@ -2,15 +2,6 @@ package com.qonversion.android.sdk.internal.remoteconfig import java.util.ArrayDeque -internal data class RemoteConfigFetchBinding( - val scope: RemoteConfigSnapshotScope, - val expectation: RemoteConfigSnapshotEnvelopeExpectation, -) { - init { - require(scope.environment == expectation.environmentUid) - } -} - internal enum class RemoteConfigFetchForceReason { Build, Identify, @@ -80,12 +71,30 @@ internal data class RemoteConfigFetchRequest( ) internal sealed class RemoteConfigFetchResponse { - data class Success(val body: ByteArray, val etag: String) : RemoteConfigFetchResponse() + /** + * [projectId] is the project the transport's session was minted for — the SDK's only source for + * it — and is what the envelope's own project id is admitted against. + */ + data class Success( + val body: ByteArray, + val etag: String, + val projectId: Long, + ) : RemoteConfigFetchResponse() + data class NotModified(val etag: String? = null) : RemoteConfigFetchResponse() data class Failure( val statusCode: Int? = null, val retryAfterMillis: Long? = null, ) : RemoteConfigFetchResponse() + + /** + * The transport was answered for a different project than the one this installation + * established. Permanent until the gateway is fixed, and the refusal costs a bootstrap round + * trip every time, so it feeds the failure backoff: forced fetches bypass the minimum interval + * but NOT the backoff gate, which is what keeps an identify/logout loop from turning a + * misrouted gateway into a request storm. + */ + data object ProjectMismatch : RemoteConfigFetchResponse() } internal fun interface RemoteConfigFetchTransport { @@ -102,6 +111,7 @@ internal sealed class RemoteConfigFetchResult { data class PolicyPersistenceFailed(val result: RemoteConfigFetchResult) : RemoteConfigFetchResult() data object InvalidNotModified : RemoteConfigFetchResult() data object Superseded : RemoteConfigFetchResult() + data object ProjectMismatch : RemoteConfigFetchResult() } internal class RemoteConfigFetchCoordinator( @@ -119,19 +129,19 @@ internal class RemoteConfigFetchCoordinator( private val deliveryLock = Any() private val pendingDeliveries = ArrayDeque() private var isDrainingDeliveries = false - private var binding: RemoteConfigFetchBinding? = null + private var boundScope: RemoteConfigSnapshotScope? = null private var operationGeneration = 0L private var inFlight: InFlight? = null private var policyState = RemoteConfigFetchPolicyState() - fun transitionTo(nextBinding: RemoteConfigFetchBinding?) { + fun transitionTo(nextScope: RemoteConfigSnapshotScope?) { val persistenceFailure = synchronized(operationLock) { synchronized(lock) { operationGeneration = nextGeneration(operationGeneration) - binding = nextBinding - core.setScope(nextBinding?.scope) - val loaded = nextBinding?.let { - loadPolicyState(RemoteConfigFetchPolicyScope.from(it.scope)) + boundScope = nextScope + core.setScope(nextScope) + val loaded = nextScope?.let { + loadPolicyState(RemoteConfigFetchPolicyScope.from(it)) } ?: LoadedPolicyState(RemoteConfigFetchPolicyState()) policyState = loaded.state val superseded = inFlight?.let { operation -> @@ -179,19 +189,19 @@ internal class RemoteConfigFetchCoordinator( // A request with no live waiters continues in the transport, but a new caller owns a new // admission token. This fences the zombie response without relying on HTTP cancellation. inFlight = null - val currentBinding = binding + val currentScope = boundScope ?: return FetchDecision.immediate(operationGeneration, RemoteConfigFetchResult.Superseded) fetchGateLocked(forceReason, nowMillis())?.let { gate -> return FetchDecision.immediate(operationGeneration, gate) } - val admission = core.beginAdmission(currentBinding.scope, currentBinding.expectation) + val admission = core.beginAdmission(currentScope) ?: return FetchDecision.immediate( operationGeneration, RemoteConfigFetchResult.Failed(statusCode = null), ) val operation = InFlight( generation = operationGeneration, - binding = currentBinding, + scope = currentScope, admission = admission, waiters = mutableListOf(), conditionalValidator = core.conditionalRequestValidator(), @@ -306,7 +316,7 @@ internal class RemoteConfigFetchCoordinator( return@synchronized NotModifiedDisposition.Accept } if (operation.didRetryWithoutETag) return@synchronized NotModifiedDisposition.Reject - val refreshedAdmission = core.beginAdmission(operation.binding.scope, operation.binding.expectation) + val refreshedAdmission = core.beginAdmission(operation.scope) ?: return@synchronized NotModifiedDisposition.Reject operation.didRetryWithoutETag = true operation.conditionalValidator = null @@ -320,7 +330,12 @@ internal class RemoteConfigFetchCoordinator( notModifiedDisposition: NotModifiedDisposition, ): ResponseOutcome = when (response) { is RemoteConfigFetchResponse.Success -> { - val transition = core.admitCandidate(operation.admission, response.body, response.etag) + val transition = core.admitCandidate( + admissionToken = operation.admission, + body = response.body, + etag = response.etag, + projectId = response.projectId, + ) val succeeded = transition.status == RemoteConfigSnapshotTransitionStatus.Accepted || transition.status == RemoteConfigSnapshotTransitionStatus.Activated ResponseOutcome( @@ -341,6 +356,10 @@ internal class RemoteConfigFetchCoordinator( result = RemoteConfigFetchResult.Failed(response.statusCode), nextPolicyState = retryableFailureState(response).takeIf { response.isRetryable() }, ) + RemoteConfigFetchResponse.ProjectMismatch -> ResponseOutcome( + result = RemoteConfigFetchResult.ProjectMismatch, + nextPolicyState = retryableFailureState(RemoteConfigFetchResponse.Failure()), + ) } private fun scheduleTimeout(operation: InFlight, waiter: FetchWaiter) { @@ -585,14 +604,14 @@ internal class RemoteConfigFetchCoordinator( private data class InFlight( val generation: Long, - val binding: RemoteConfigFetchBinding, + val scope: RemoteConfigSnapshotScope, var admission: RemoteConfigSnapshotAdmissionToken, val waiters: MutableList, var conditionalValidator: RemoteConfigConditionalRequestValidator?, var attemptOrdinal: Long = 0, var didRetryWithoutETag: Boolean = false, ) { - val policyScope: RemoteConfigFetchPolicyScope = RemoteConfigFetchPolicyScope.from(binding.scope) + val policyScope: RemoteConfigFetchPolicyScope = RemoteConfigFetchPolicyScope.from(scope) } private class FetchWaiter( diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewaySession.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewaySession.kt index 6ec981174..a44fad784 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewaySession.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewaySession.kt @@ -8,6 +8,8 @@ import java.nio.ByteBuffer import java.security.MessageDigest private const val REMOTE_CONFIG_SESSION_PREFIX = "qonversion_remote_config_v2_session_" +private const val REMOTE_CONFIG_PROJECT_ID_PREFIX = "qonversion_remote_config_v2_project_" +private const val REMOTE_CONFIG_PROJECT_ID_MAX_CHARS = 32 private const val REMOTE_CONFIG_SESSION_VERSION = 1 private const val REMOTE_CONFIG_SESSION_MAX_BYTES = 4 * 1024 private const val REMOTE_CONFIG_SESSION_TOKEN_MAX_BYTES = 2 * 1024 @@ -152,6 +154,125 @@ internal data class PersistedRemoteConfigGatewaySession( val expiresAtMillis: Long, ) +/** + * Outcome of offering a bootstrapped project id to [RemoteConfigProjectIdRegistry]. + * + * There is deliberately no "re-learned" outcome: the project id is the one piece of envelope + * addressing that is stable for the lifetime of an installation, so a gateway answering with a + * different one is a routing or configuration fault, never a legitimate rotation. + */ +internal enum class RemoteConfigProjectIdOutcome { + Established, + + /** The offered id disagrees with the one already established. Permanent. */ + Conflict, + + /** The offered id could never address anything. Says nothing about the established one. */ + Unusable, +} + +/** Durable storage of the project id a bootstrap established for one project key + environment. */ +internal interface RemoteConfigProjectIdStore { + fun load(scope: RemoteConfigSnapshotScope): Long? + fun save(scope: RemoteConfigSnapshotScope, projectId: Long): Boolean +} + +/** + * Remembers the numeric project id the gateway bootstrapped, so a served snapshot can be checked + * against something the SDK learned rather than something the app typed. + * + * The record is keyed by project key + environment and NOT by the canonical user id: the project id + * addresses the app's project, not one identity inside it. Keying it per identity would both forget + * the pin on every login and hide the case worth catching — one identity's session being answered + * for a different project than another's. + * + * The first bootstrap establishes the value; every later one must agree with it. The + * in-memory pin is authoritative for the process even when the durable write fails, so a storage + * failure can never downgrade a conflict into a silent re-learn. + */ +internal class RemoteConfigProjectIdRegistry(private val store: RemoteConfigProjectIdStore) { + private val established = mutableMapOf() + + @Synchronized + fun establish(scope: RemoteConfigSnapshotScope, projectId: Long): RemoteConfigProjectIdOutcome { + // Reported apart from a conflict on purpose: an id that addresses nothing is a malformed + // answer, not evidence that this installation is talking to the wrong project. + if (projectId <= 0) return RemoteConfigProjectIdOutcome.Unusable + val known = establishedLocked(scope) + return when { + known == projectId -> RemoteConfigProjectIdOutcome.Established + known != null -> RemoteConfigProjectIdOutcome.Conflict + else -> { + established[RemoteConfigProjectIdScope.from(scope)] = projectId + try { + store.save(scope, projectId) + } catch (_: Exception) { + // The in-process pin still fences this run; the next start re-establishes it. + } + RemoteConfigProjectIdOutcome.Established + } + } + } + + private fun establishedLocked(scope: RemoteConfigSnapshotScope): Long? { + val key = RemoteConfigProjectIdScope.from(scope) + return established[key] ?: loadPersisted(scope)?.also { established[key] = it } + } + + private fun loadPersisted(scope: RemoteConfigSnapshotScope): Long? = try { + store.load(scope) + } catch (_: Exception) { + null + }?.takeIf { it > 0 } +} + +private data class RemoteConfigProjectIdScope(val projectKey: String, val environment: String) { + companion object { + fun from(scope: RemoteConfigSnapshotScope) = + RemoteConfigProjectIdScope(scope.projectKey, scope.environment) + } +} + +/** + * Cache-backed [RemoteConfigProjectIdStore]. + * + * Mirrors [PersistentRemoteConfigSessionStore]: the storage key is a salted digest, so the project + * key never lands in a preference name. The value is a plain decimal, and anything that does not + * read back as a positive number is treated as absent rather than trusted. + */ +internal class PersistentRemoteConfigProjectIdStore(private val cache: Cache) : RemoteConfigProjectIdStore { + @Synchronized + override fun load(scope: RemoteConfigSnapshotScope): Long? { + val raw = try { + cache.getString(remoteConfigProjectIdStorageKey(scope), null) + } catch (_: Exception) { + null + } ?: return null + return raw.takeIf { it.length <= REMOTE_CONFIG_PROJECT_ID_MAX_CHARS }?.trim()?.toLongOrNull()?.takeIf { it > 0 } + } + + @Synchronized + override fun save(scope: RemoteConfigSnapshotScope, projectId: Long): Boolean { + if (projectId <= 0) return false + return try { + cache.updateStringsDurably( + values = mapOf(remoteConfigProjectIdStorageKey(scope) to projectId.toString()), + removedKeys = emptySet(), + ) + } catch (_: Exception) { + false + } + } +} + +private fun remoteConfigProjectIdStorageKey(scope: RemoteConfigSnapshotScope): String { + val digest = MessageDigest.getInstance("SHA-256") + digest.updateLengthPrefixed("remote-config-gateway-project-id-v1".encodeToByteArray()) + digest.updateLengthPrefixed(scope.projectKey.encodeToByteArray()) + digest.updateLengthPrefixed(scope.environment.encodeToByteArray()) + return REMOTE_CONFIG_PROJECT_ID_PREFIX + digest.digest().joinToString("") { byte -> "%02x".format(byte) } +} + private fun remoteConfigSessionStorageKey(key: RemoteConfigSessionKey): String { val digest = MessageDigest.getInstance("SHA-256") digest.updateLengthPrefixed("remote-config-gateway-session-v1".encodeToByteArray()) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransport.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransport.kt index 3f234e4fa..a5c5b4384 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransport.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransport.kt @@ -113,6 +113,11 @@ internal fun interface RemoteConfigTransportIdentityProvider { * is a typed failure, never another bootstrap — the flow cannot loop. * 4. Hand the response body to the coordinator as the EXACT bytes received, paired with the exact * `ETag` header. Nothing is decoded, re-encoded or charset-converted on the way in. + * 5. Publish the project id the session was minted for, which is what the admission check compares + * the envelope against. The bootstrap is the SDK's only source for it, so it is established here + * (see [RemoteConfigProjectIdRegistry]) rather than configured by the app, and a session whose + * project id contradicts the established one is refused as + * [RemoteConfigFetchResponse.ProjectMismatch] instead of being used for a read. * * The completion is invoked exactly once on every path, including one that throws on an OkHttp * dispatcher thread: the coordinator parks a waiter on it, and a lost completion would strand that @@ -131,6 +136,7 @@ internal class RemoteConfigGatewayTransport( private val identityProvider: RemoteConfigTransportIdentityProvider, private val clientContextProvider: RemoteConfigClientContextProvider, private val sessionStore: RemoteConfigSessionStore, + private val projectIds: RemoteConfigProjectIdRegistry, private val clock: RemoteConfigFetchClock, moshi: Moshi, private val logger: Logger, @@ -164,7 +170,35 @@ internal class RemoteConfigGatewayTransport( requestSnapshot(identity, context, minted, request, deliver, allowReBootstrap = false) } } else { - requestSnapshot(identity, context, session, request, deliver, allowReBootstrap = true) + establishProjectId(identity, session)?.let { refusal -> deliver(refusal) } + ?: requestSnapshot(identity, context, session, request, deliver, allowReBootstrap = true) + } + } + + /** + * Pins the project id this session was minted for, or returns the response that refuses it. + * + * A refused session is dropped rather than merely skipped for this fetch: it addresses a + * project this installation has never read, so keeping it would replay the same refusal on + * every later fetch. + */ + private fun establishProjectId( + identity: RemoteConfigTransportIdentity, + session: RemoteConfigGatewaySession, + ): RemoteConfigFetchResponse? { + val outcome = projectIds.establish(identity.scope, session.projectId) + if (outcome == RemoteConfigProjectIdOutcome.Established) return null + forgetSession(identity.sessionKey) + return if (outcome == RemoteConfigProjectIdOutcome.Conflict) { + logger.error( + "Remote Config v2 refused a gateway session: it was minted for a different " + + "project than the one this installation established", + ) + RemoteConfigFetchResponse.ProjectMismatch + } else { + // A malformed answer, not an addressing fault: it stays an ordinary failure. + logger.debug("Remote Config v2 refused a gateway session without a usable project id") + RemoteConfigFetchResponse.Failure() } } @@ -195,7 +229,7 @@ internal class RemoteConfigGatewayTransport( return } enqueue(httpRequest, deliver) { outcome -> - onSnapshotOutcome(identity, context, request, deliver, allowReBootstrap, outcome) + onSnapshotOutcome(identity, context, session, request, deliver, allowReBootstrap, outcome) } } @@ -203,6 +237,7 @@ internal class RemoteConfigGatewayTransport( private fun onSnapshotOutcome( identity: RemoteConfigTransportIdentity, context: RemoteConfigClientContext, + session: RemoteConfigGatewaySession, request: RemoteConfigFetchRequest, deliver: SingleDelivery, allowReBootstrap: Boolean, @@ -210,7 +245,9 @@ internal class RemoteConfigGatewayTransport( ) { when { outcome == null -> deliver(RemoteConfigFetchResponse.Failure()) - outcome.code == HTTP_OK -> deliver(outcome.asSuccessOrFailure()) + // The project id travels with the session that authorised this exact read, so the + // admission check compares the envelope against the session it was served for. + outcome.code == HTTP_OK -> deliver(outcome.asSuccessOrFailure(session.projectId)) outcome.code == HTTP_NOT_MODIFIED -> deliver(RemoteConfigFetchResponse.NotModified(outcome.etag)) outcome.code == HTTP_UNAUTHORIZED -> { @@ -255,6 +292,12 @@ internal class RemoteConfigGatewayTransport( deliver(if (outcome?.code == HTTP_OK) RemoteConfigFetchResponse.Failure() else outcome.asFailure()) return@enqueue } + // Established BEFORE the session is remembered: a session minted for a project this + // installation has never read must not survive the fetch that revealed the conflict. + establishProjectId(identity, session)?.let { refusal -> + deliver(refusal) + return@enqueue + } rememberSession(identity.sessionKey, session) onMinted(session) } @@ -450,15 +493,15 @@ internal class RemoteConfigGatewayTransport( val etag: String?, val retryAfterMillis: Long?, ) { - fun asSuccessOrFailure(): RemoteConfigFetchResponse { - val bytes = body - val validator = etag - return if (bytes == null || bytes.isEmpty() || validator.isNullOrEmpty()) { - // An empty body, an over-budget body or a 200 without a strong validator cannot be - // admitted, and none of them is retryable. - RemoteConfigFetchResponse.Failure() + fun asSuccessOrFailure(projectId: Long): RemoteConfigFetchResponse { + val bytes = body?.takeIf { it.isNotEmpty() } + val validator = etag?.takeIf { it.isNotEmpty() } + // An empty body, an over-budget body, a 200 without a strong validator or a session + // carrying no usable project id cannot be admitted, and none of them is retryable. + return if (bytes != null && validator != null && projectId > 0) { + RemoteConfigFetchResponse.Success(bytes, validator, projectId) } else { - RemoteConfigFetchResponse.Success(bytes, validator) + RemoteConfigFetchResponse.Failure() } } } diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCore.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCore.kt index 105120753..e56d212d9 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCore.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCore.kt @@ -36,14 +36,12 @@ internal class RemoteConfigSnapshotAdmissionToken private constructor( ordinal: Long, scope: RemoteConfigSnapshotScope, scopeGeneration: Long, - expectation: RemoteConfigSnapshotEnvelopeExpectation, ) = RemoteConfigSnapshotAdmissionToken( ownerNonce = ownerNonce, admission = BoundRemoteConfigSnapshotAdmission( ordinal = ordinal, scope = scope, scopeGeneration = scopeGeneration, - expectation = expectation, ), ) } @@ -84,7 +82,6 @@ internal data class BoundRemoteConfigSnapshotAdmission( val ordinal: Long, val scope: RemoteConfigSnapshotScope, val scopeGeneration: Long, - val expectation: RemoteConfigSnapshotEnvelopeExpectation, ) internal data class RemoteConfigConditionalRequestValidator( @@ -301,19 +298,22 @@ internal class RemoteConfigSnapshotCore( synchronized(lock) { observers.remove(token) } } - fun beginAdmission( - scope: RemoteConfigSnapshotScope, - expectation: RemoteConfigSnapshotEnvelopeExpectation, - ): RemoteConfigSnapshotAdmissionToken? = + /** + * Claims the right to admit the next release for [scope]. + * + * The envelope expectation is deliberately NOT taken here: its environment uid is the scope's + * own, and its project id is only known once the transport has bootstrapped a session — which + * happens after this claim is made. It is therefore supplied to [admitCandidate], the step that + * actually has the response in hand. + */ + fun beginAdmission(scope: RemoteConfigSnapshotScope): RemoteConfigSnapshotAdmissionToken? = synchronized(lock) { - if (expectation.environmentUid != scope.environment) return@synchronized null val admission = issueAdmissionLocked(scope) ?: return@synchronized null RemoteConfigSnapshotAdmissionToken.issue( ownerNonce = admissionOwnerNonce, ordinal = admission.ordinal, scope = scope, scopeGeneration = admission.scopeGeneration, - expectation = expectation, ) } @@ -360,11 +360,19 @@ internal class RemoteConfigSnapshotCore( ) } + /** + * Admits [body] under a claim taken by [beginAdmission]. + * + * [projectId] is the project the response was served for, as established by the gateway session + * that authorised the read. The envelope must name exactly it — and the admitting scope's + * environment — or it is [RemoteConfigSnapshotTransitionStatus.Rejected]. + */ @Suppress("ReturnCount") fun admitCandidate( admissionToken: RemoteConfigSnapshotAdmissionToken, body: ByteArray, etag: String, + projectId: Long, ): RemoteConfigSnapshotTransitionResult { val admission = admissionToken.resolve(admissionOwnerNonce) ?: return RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.Rejected) @@ -377,7 +385,11 @@ internal class RemoteConfigSnapshotCore( if (!tokenIsCurrent) { return RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.Rejected) } - val envelope = envelopeParser.parse(body, etag, admission.expectation) + val expectation = RemoteConfigSnapshotEnvelopeExpectation( + projectId = projectId, + environmentUid = admission.scope.environment, + ) + val envelope = envelopeParser.parse(body, etag, expectation) ?: return RemoteConfigSnapshotTransitionResult(RemoteConfigSnapshotTransitionStatus.Rejected) return acceptCandidate( scope = admission.scope, diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParser.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParser.kt index e05c97f1f..ee9a4a2d1 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParser.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotEnvelopeParser.kt @@ -23,8 +23,12 @@ private val PORTABLE_JSON_MIN_INTEGER_BIG = PORTABLE_JSON_MAX_INTEGER_BIG.negate private val LOWERCASE_SHA256_PATTERN = Regex("^[0-9a-f]{64}$") /** - * The addressing an envelope must match to be admitted: exactly the project and environment the SDK - * was configured for. + * The addressing an envelope must match to be admitted: exactly the environment the SDK was + * configured for and the project its gateway session was minted for. + * + * [projectId] is learned, not configured: the session bootstrap is the SDK's only source for it, + * the first bootstrap of a scope pins it, and a later disagreement is refused before a snapshot is + * ever read (see [RemoteConfigProjectIdRegistry]). * * The targeting context is deliberately absent. The fingerprint hashes mutable targeting context * (app/OS version, locale, purchases, properties); it rotates legitimately and MUST NOT be pinned diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt index 07fe65ceb..a23a11d44 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt @@ -72,7 +72,7 @@ internal object RemoteConfigV2Factory { val store = PersistentRemoteConfigSnapshotStore(cache, moshi) val core = RemoteConfigSnapshotCore(store, bundledRelease(application, primaryConfig.projectKey)) // One single-threaded worker for BOTH the preloader and the manager: the manager's - // ordering contract (preload installs before a binding change observes the scope) is + // ordering contract (preload installs before a scope transition is observed) is // exactly this executor's FIFO ordering. val worker = Executors.newSingleThreadExecutor(daemonThreadFactory(REMOTE_CONFIG_V2_WORKER_THREAD_NAME)) val scheduler = scheduler() @@ -115,7 +115,6 @@ internal object RemoteConfigV2Factory { options = RemoteConfigV2Options( projectKey = primaryConfig.projectKey, environmentUid = config.environmentUid, - projectId = config.projectId, ), scopeHolder = scopeHolder, scheduler = scheduler, @@ -161,6 +160,9 @@ internal object RemoteConfigV2Factory { sdkVersion = internalConfig.primaryConfig.sdkVersion, ), sessionStore = PersistentRemoteConfigSessionStore(cache, moshi), + // Durable per project key + environment: the project id the first bootstrap established + // must outlive both the session that carried it and the process that learned it. + projectIds = RemoteConfigProjectIdRegistry(PersistentRemoteConfigProjectIdStore(cache)), clock = clock, moshi = moshi, logger = logger, diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Manager.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Manager.kt index 684ed1223..f79fdc559 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Manager.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Manager.kt @@ -23,11 +23,13 @@ internal const val REMOTE_CONFIG_V2_DEFAULT_FETCH_TIMEOUT_MILLIS = 5_000L * mutable targeting context (app/OS version, locale, purchases, properties); it rotates legitimately * and MUST NOT be pinned across fetches. Identity isolation is the session's job — see * [RemoteConfigGatewaySession] and the per-scope storage keys. + * + * The numeric project id is not part of it either: the SDK learns it from the session bootstrap + * rather than from the app, and it travels with the response it addresses. */ internal data class RemoteConfigV2Options( val projectKey: String, val environmentUid: String, - val projectId: Long, ) /** @@ -69,7 +71,7 @@ internal fun interface RemoteConfigMainDispatcher { * - every operation that can touch durable storage runs on [worker], which MUST be the same * single-threaded executor the read guard's preloader uses. That ordering is what keeps a scope * transition from racing its own preload: the preload task is enqueued first and therefore - * installs the loaded state before the coordinator's binding change observes the scope. + * installs the loaded state before the coordinator observes the new scope. */ @Suppress("LongParameterList") internal class RemoteConfigV2Manager( @@ -98,10 +100,9 @@ internal class RemoteConfigV2Manager( // concurrent fetch reads the new identity and admits its snapshot into the old store. readGuard.transitionScopeBeforeSdkReady(scope) scopeHolder.scope = scope - val binding = scope?.let { RemoteConfigFetchBinding(it, expectation()) } val submitted = submit { - coordinator.transitionTo(binding) - if (binding != null) forceFetch(forceReason) + coordinator.transitionTo(scope) + if (scope != null) forceFetch(forceReason) } if (!submitted) logger.debug("Remote Config v2 could not apply an identity change") } @@ -216,11 +217,6 @@ internal class RemoteConfigV2Manager( null } - private fun expectation() = RemoteConfigSnapshotEnvelopeExpectation( - projectId = options.projectId, - environmentUid = options.environmentUid, - ) - private fun scheduleTimeout( timeoutMillis: Long?, delivery: SingleDelivery, @@ -263,13 +259,17 @@ internal class RemoteConfigV2Manager( is RemoteConfigFetchResult.PolicyPersistenceFailed -> result.toPublicResult() RemoteConfigFetchResult.InvalidNotModified -> result(QRemoteConfigFetchStatus.Failed) RemoteConfigFetchResult.Superseded -> result(QRemoteConfigFetchStatus.Superseded) + // A permanent addressing fault the transport has already reported: no snapshot was read at + // all, so there is nothing to report beyond the failure itself. + RemoteConfigFetchResult.ProjectMismatch -> result(QRemoteConfigFetchStatus.Failed) } private fun RemoteConfigSnapshotTransitionResult.toFetchStatus(): QRemoteConfigFetchStatus = when (status) { - // Rejected covers a malformed envelope AND a snapshot whose project id or environment does - // not match the configured expectation. The latter is a permanent misconfiguration that - // otherwise looks exactly like a network failure. A changed targeting context is NOT in - // this class: it rotates on any app/OS update, locale change, purchase or property edit. + // Rejected covers a malformed envelope AND a snapshot addressed to another project or + // environment than the session it was served for. The latter is a permanent server-side + // fault that otherwise looks exactly like a network failure. A changed targeting context is + // NOT in this class: it rotates on any app/OS update, locale change, purchase or property + // edit. RemoteConfigSnapshotTransitionStatus.Accepted, RemoteConfigSnapshotTransitionStatus.Activated, RemoteConfigSnapshotTransitionStatus.Unchanged, @@ -278,8 +278,8 @@ internal class RemoteConfigV2Manager( RemoteConfigSnapshotTransitionStatus.PersistenceFailed -> QRemoteConfigFetchStatus.Failed RemoteConfigSnapshotTransitionStatus.Rejected -> { logger.error( - "Remote Config v2 refused a snapshot: it did not match the configured project id " + - "or environment uid, or the envelope was malformed", + "Remote Config v2 refused a snapshot: it did not match the project id or " + + "environment uid of the session it was served for, or the envelope was malformed", ) QRemoteConfigFetchStatus.Failed } diff --git a/sdk/src/test/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2ConfigTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2ConfigTest.kt index b4421d3e3..bc215250d 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2ConfigTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2ConfigTest.kt @@ -4,6 +4,7 @@ package com.qonversion.android.sdk.dto.remoteconfig import com.qonversion.android.sdk.ExperimentalQonversionApi import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertThrows import org.junit.Test @@ -18,12 +19,10 @@ internal class QRemoteConfigV2ConfigTest { val config = QRemoteConfigV2Config( baseUrl = "https://gateway.example.com/", environmentUid = "production", - projectId = 42, ) assertEquals("https://gateway.example.com/", config.baseUrl) assertEquals("production", config.environmentUid) - assertEquals(42L, config.projectId) } @Test @@ -33,8 +32,6 @@ internal class QRemoteConfigV2ConfigTest { "scheme-less base url" to { config(baseUrl = "//gateway.example.com") }, "empty environment" to { config(environmentUid = "") }, "over-long environment" to { config(environmentUid = "e".repeat(37)) }, - "zero project id" to { config(projectId = 0) }, - "negative project id" to { config(projectId = -1) }, ) malformed.forEach { (name, build) -> @@ -42,9 +39,22 @@ internal class QRemoteConfigV2ConfigTest { } } + @Test + fun `the configuration neither takes nor exposes a project id`() { + // The numeric project id is learned from the gateway session bootstrap. Re-introducing it + // here would put a value the app cannot verify back into the public surface. Asserted by + // name rather than by shape, so an unrelated field of the same type does not fail this. + val members = QRemoteConfigV2Config::class.java.declaredFields.map { it.name } + + QRemoteConfigV2Config::class.java.declaredMethods.map { it.name } + members.forEach { name -> assertFalse(name, name.contains("rojectId")) } + assertEquals( + setOf("baseUrl", "environmentUid"), + QRemoteConfigV2Config::class.java.declaredFields.map { it.name }.toSet(), + ) + } + private fun config( baseUrl: String = "https://gateway.example.com/", environmentUid: String = "production", - projectId: Long = 42, - ) = QRemoteConfigV2Config(baseUrl, environmentUid, projectId) + ) = QRemoteConfigV2Config(baseUrl, environmentUid) } diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinatorTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinatorTest.kt index d6eaf3626..1ea0ff370 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinatorTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigFetchCoordinatorTest.kt @@ -15,19 +15,12 @@ import java.util.concurrent.atomic.AtomicBoolean internal class RemoteConfigFetchCoordinatorTest { private val scope = RemoteConfigSnapshotScope("project", "production", "canonical-user") - private val binding = RemoteConfigFetchBinding( - scope = scope, - expectation = RemoteConfigSnapshotEnvelopeExpectation( - projectId = 42, - environmentUid = "production", - ), - ) @Test fun `concurrent fetches coalesce into one transport request`() { val transport = RecordingTransport() val coordinator = coordinator(transport) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val results = mutableListOf() coordinator.fetch(callback = results::add) @@ -49,7 +42,7 @@ internal class RemoteConfigFetchCoordinatorTest { policyStore = policyStore, policy = RemoteConfigFetchPolicy(minimumFetchIntervalMillis = 60_000), ) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) coordinator.fetch(callback = {}) transport.complete(success("first", 1)) @@ -73,7 +66,7 @@ internal class RemoteConfigFetchCoordinatorTest { maximumBackoffMillis = 10_000, ) val first = coordinator(transport, clock, policyStore, policy) - first.transitionTo(binding) + first.transitionTo(scope) first.fetch(callback = {}) transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 429, retryAfterMillis = 4_000)) @@ -84,7 +77,7 @@ internal class RemoteConfigFetchCoordinatorTest { val restartedTransport = RecordingTransport() val restarted = coordinator(restartedTransport, clock, policyStore, policy) - restarted.transitionTo(binding) + restarted.transitionTo(scope) val beforeDeadline = mutableListOf() restarted.fetch(callback = beforeDeadline::add) assertTrue(beforeDeadline.single() is RemoteConfigFetchResult.Backoff) @@ -108,13 +101,11 @@ internal class RemoteConfigFetchCoordinatorTest { maximumBackoffMillis = 10_000, ), ) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) coordinator.fetch(callback = {}) transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 429, retryAfterMillis = 4_000)) - coordinator.transitionTo( - binding.copy(scope = RemoteConfigSnapshotScope("project", "production", "identified-user")), - ) + coordinator.transitionTo(RemoteConfigSnapshotScope("project", "production", "identified-user")) val result = mutableListOf() coordinator.fetch(forceReason = RemoteConfigFetchForceReason.Identify, callback = result::add) @@ -139,7 +130,7 @@ internal class RemoteConfigFetchCoordinatorTest { maximumBackoffMillis = 1_500, ), ) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) coordinator.fetch(callback = {}) transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 500)) assertEquals( @@ -185,7 +176,7 @@ internal class RemoteConfigFetchCoordinatorTest { scheduler = scheduler, policy = RemoteConfigFetchPolicy(minimumFetchIntervalMillis = 0, timeoutMillis = 100), ) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) core.acceptCandidate(scope, release("active", 1, "1")) core.activate() val results = mutableListOf() @@ -212,7 +203,7 @@ internal class RemoteConfigFetchCoordinatorTest { scheduler = scheduler, policy = RemoteConfigFetchPolicy(minimumFetchIntervalMillis = 0, timeoutMillis = 100), ) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val first = mutableListOf() val second = mutableListOf() coordinator.fetch(callback = first::add) @@ -244,7 +235,7 @@ internal class RemoteConfigFetchCoordinatorTest { scheduler = scheduler, policy = RemoteConfigFetchPolicy(minimumFetchIntervalMillis = 0, timeoutMillis = 100), ) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val results = mutableListOf() coordinator.fetch(callback = results::add) @@ -259,7 +250,7 @@ internal class RemoteConfigFetchCoordinatorTest { val transport = RecordingTransport() val core = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundledRelease = null) val coordinator = coordinator(transport = transport, core = core) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val first = success("first", 1) coordinator.fetch(callback = {}) transport.complete(first) @@ -278,7 +269,7 @@ internal class RemoteConfigFetchCoordinatorTest { val transport = RecordingTransport() val core = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundledRelease = null) val coordinator = coordinator(transport = transport, core = core) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) coordinator.fetch(callback = {}) transport.complete(success("active", 1)) core.activate() @@ -294,7 +285,7 @@ internal class RemoteConfigFetchCoordinatorTest { val transport = RecordingTransport() val core = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundledRelease = null) val coordinator = coordinator(transport = transport, core = core) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val canonical = success("canonical", 1) coordinator.fetch(callback = {}) transport.complete(canonical) @@ -315,7 +306,7 @@ internal class RemoteConfigFetchCoordinatorTest { val transport = RecordingTransport() val core = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundledRelease = null) val coordinator = coordinator(transport = transport, core = core) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val results = mutableListOf() coordinator.fetch(callback = results::add) @@ -335,37 +326,36 @@ internal class RemoteConfigFetchCoordinatorTest { val transport = RecordingTransport() val core = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundledRelease = null) val coordinator = coordinator(transport = transport, core = core) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val oldResults = mutableListOf() coordinator.fetch(callback = oldResults::add) - val nextBinding = binding.copy( - scope = RemoteConfigSnapshotScope("project", "production", "canonical-user-next"), - expectation = binding.expectation.copy(projectId = 43), - ) - coordinator.transitionTo(nextBinding) + val nextScope = RemoteConfigSnapshotScope("project", "production", "canonical-user-next") + coordinator.transitionTo(nextScope) assertEquals(listOf(RemoteConfigFetchResult.Superseded), oldResults) transport.complete(success("late-private", 1)) assertEquals(null, core.lastFetchedSnapshot()) val nextResults = mutableListOf() coordinator.fetch(forceReason = RemoteConfigFetchForceReason.Identify, callback = nextResults::add) - transport.complete(success("wrong-project", 1)) + // The session the response arrived on was minted for another project than the envelope + // names, so the admission is refused rather than stored under the new identity. + transport.complete(success("wrong-project", 1, projectId = 43)) val transition = (nextResults.single() as RemoteConfigFetchResult.Fetched).transition assertEquals(RemoteConfigSnapshotTransitionStatus.Rejected, transition.status) assertEquals(null, core.lastFetchedSnapshot()) } @Test - fun `same visible binding can be explicitly generation fenced on identify`() { + fun `same visible scope can be explicitly generation fenced on identify`() { val transport = RecordingTransport() val core = RemoteConfigSnapshotCore(InMemorySnapshotStore(), bundledRelease = null) val coordinator = coordinator(transport = transport, core = core) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val results = mutableListOf() coordinator.fetch(callback = results::add) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) transport.complete(success("stale", 1)) assertEquals(listOf(RemoteConfigFetchResult.Superseded), results) @@ -388,7 +378,7 @@ internal class RemoteConfigFetchCoordinatorTest { envelopeParser = parser, ) val coordinator = coordinator(transport = transport, core = core) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val events = Collections.synchronizedList(mutableListOf()) coordinator.fetch { events += "callback" } @@ -420,7 +410,7 @@ internal class RemoteConfigFetchCoordinatorTest { fun `one throwing coalesced callback cannot starve the remaining waiters`() { val transport = RecordingTransport() val coordinator = coordinator(transport) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val delivered = mutableListOf() coordinator.fetch { throw AssertionError("consumer failure") } coordinator.fetch(callback = delivered::add) @@ -434,7 +424,7 @@ internal class RemoteConfigFetchCoordinatorTest { fun `reentrant identity transition converts every remaining claimed callback to Superseded`() { val transport = RecordingTransport() val coordinator = coordinator(transport) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val first = mutableListOf() val second = mutableListOf() coordinator.fetch { result -> @@ -453,7 +443,7 @@ internal class RemoteConfigFetchCoordinatorTest { fun `callback delivery holds no coordinator monitor needed by a concurrent transition`() { val transport = RecordingTransport() val coordinator = coordinator(transport) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val transitionCompletedInsideCallback = AtomicBoolean(false) coordinator.fetch { val completed = CountDownLatch(1) @@ -480,7 +470,7 @@ internal class RemoteConfigFetchCoordinatorTest { maximumBackoffMillis = 10_000, ) val coordinator = coordinator(transport, clock, policyStore, policy) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val failed = mutableListOf() coordinator.fetch(callback = failed::add) transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 500)) @@ -492,7 +482,7 @@ internal class RemoteConfigFetchCoordinatorTest { val restartedTransport = RecordingTransport() val restarted = coordinator(restartedTransport, clock, policyStore, policy) - restarted.transitionTo(binding) + restarted.transitionTo(scope) restarted.fetch(callback = {}) assertEquals(1, restartedTransport.requests.size) } @@ -500,7 +490,7 @@ internal class RemoteConfigFetchCoordinatorTest { @Test fun `transport and timeout scheduler failures still complete or continue the operation`() { val transportFailure = coordinator(RemoteConfigFetchTransport { _, _ -> error("transport") }) - transportFailure.transitionTo(binding) + transportFailure.transitionTo(scope) val failed = mutableListOf() transportFailure.fetch(callback = failed::add) assertTrue(failed.single() is RemoteConfigFetchResult.Failed) @@ -511,7 +501,7 @@ internal class RemoteConfigFetchCoordinatorTest { scheduler = RemoteConfigFetchScheduler { _, _ -> error("scheduler") }, policy = RemoteConfigFetchPolicy(minimumFetchIntervalMillis = 0, timeoutMillis = 100), ) - schedulerFailure.transitionTo(binding) + schedulerFailure.transitionTo(scope) val recovered = mutableListOf() schedulerFailure.fetch(callback = recovered::add) transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 400)) @@ -539,7 +529,7 @@ internal class RemoteConfigFetchCoordinatorTest { maximumBackoffMillis = 10_000, ), ) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val result = mutableListOf() coordinator.fetch(callback = result::add) @@ -572,7 +562,7 @@ internal class RemoteConfigFetchCoordinatorTest { maximumBackoffMillis = 10_000, ), ) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) coordinator.fetch(callback = {}) transport.complete(RemoteConfigFetchResponse.Failure(statusCode = 500)) assertTrue( @@ -605,9 +595,9 @@ internal class RemoteConfigFetchCoordinatorTest { ) } - private fun success(uid: String, number: Long): RemoteConfigFetchResponse.Success { + private fun success(uid: String, number: Long, projectId: Long = 42): RemoteConfigFetchResponse.Success { val body = wireBody(uid, number).encodeToByteArray() - return RemoteConfigFetchResponse.Success(body, strongETag(body)) + return RemoteConfigFetchResponse.Success(body, strongETag(body), projectId) } private fun wireBody(uid: String, number: Long) = diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportCoordinatorTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportCoordinatorTest.kt index a6ce9d8c5..6d1c5166e 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportCoordinatorTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportCoordinatorTest.kt @@ -14,6 +14,7 @@ import org.junit.After import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -49,7 +50,7 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { fun `server bytes reach durable admission unchanged`() { val core = core() val coordinator = coordinator(core) - coordinator.transitionTo(BINDING) + coordinator.transitionTo(SCOPE) val body = WIRE_BODY.toByteArray(Charsets.UTF_8) server.enqueue(sessionResponse()) server.enqueue(snapshotResponse(body, strongETag(body))) @@ -71,7 +72,7 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { fun `304 is recovered against the current head instead of re-admitting`() { val core = core() val coordinator = coordinator(core) - coordinator.transitionTo(BINDING) + coordinator.transitionTo(SCOPE) val body = WIRE_BODY.toByteArray(Charsets.UTF_8) server.enqueue(sessionResponse()) server.enqueue(snapshotResponse(body, strongETag(body))) @@ -89,11 +90,58 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { assertEquals(strongETag(body), conditional.getHeader("If-None-Match")) } + @Test + fun `the bootstrapped project id is what a snapshot is admitted against`() { + // Nothing in the app configured 43: the session bootstrap alone establishes the project the + // snapshot must belong to, and this envelope names 42. + val core = core() + val coordinator = coordinator(core) + coordinator.transitionTo(SCOPE) + val body = WIRE_BODY.toByteArray(Charsets.UTF_8) + server.enqueue(sessionResponse(projectId = 43)) + server.enqueue(snapshotResponse(body, strongETag(body))) + + val result = fetch(coordinator) + + assertEquals( + RemoteConfigSnapshotTransitionStatus.Rejected, + (result as RemoteConfigFetchResult.Fetched).transition.status, + ) + assertNull(snapshotStore.states[SCOPE]?.candidate) + } + + @Test + fun `a later bootstrap that changes the project id is a typed failure, not a re-learn`() { + val core = core() + val coordinator = coordinator(core) + coordinator.transitionTo(SCOPE) + val body = WIRE_BODY.toByteArray(Charsets.UTF_8) + server.enqueue(sessionResponse()) + server.enqueue(snapshotResponse(body, strongETag(body))) + assertTrue(fetch(coordinator) is RemoteConfigFetchResult.Fetched) + + // A 401 drops the established session, so the next read re-bootstraps — and this time the + // gateway answers for a different project. + server.enqueue(MockResponse().setResponseCode(401)) + server.enqueue(sessionResponse(projectId = 43)) + + assertEquals(RemoteConfigFetchResult.ProjectMismatch, fetch(coordinator)) + // Snapshot, session, snapshot, session: no read was attempted on the refused session. + assertEquals(4, server.requestCount) + + // And the refusal arms the failure backoff, so an identify/logout loop cannot turn a + // misrouted gateway into one bootstrap round trip per call. Forced fetches bypass the + // minimum interval, never this gate. + val gated = fetch(coordinator, RemoteConfigFetchForceReason.Identify) + assertTrue(gated.toString(), gated is RemoteConfigFetchResult.Backoff) + assertEquals(4, server.requestCount) + } + @Test fun `a stalled gateway times out through the fetch policy`() { val core = core() val coordinator = coordinator(core) - coordinator.transitionTo(BINDING) + coordinator.transitionTo(SCOPE) server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)) val latch = CountDownLatch(1) @@ -111,10 +159,13 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { assertNotNull(server.takeRequest(AWAIT_SECONDS, TimeUnit.SECONDS)) } - private fun fetch(coordinator: RemoteConfigFetchCoordinator): RemoteConfigFetchResult { + private fun fetch( + coordinator: RemoteConfigFetchCoordinator, + forceReason: RemoteConfigFetchForceReason? = null, + ): RemoteConfigFetchResult { val latch = CountDownLatch(1) var result: RemoteConfigFetchResult? = null - coordinator.fetch { fetchResult -> + coordinator.fetch(forceReason) { fetchResult -> result = fetchResult latch.countDown() } @@ -152,15 +203,16 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { ) }, sessionStore = InMemorySessionStore(), + projectIds = RemoteConfigProjectIdRegistry(InMemoryProjectIdStore()), clock = { CLOCK_MILLIS }, moshi = Moshi.Builder().build(), logger = SilentLogger(), ) - private fun sessionResponse() = MockResponse() + private fun sessionResponse(projectId: Long = 42) = MockResponse() .setResponseCode(200) .setBody( - "{\"session_token\":\"qrcs1.session-secret\",\"project_id\":42," + + "{\"session_token\":\"qrcs1.session-secret\",\"project_id\":$projectId," + "\"environment\":\"prod\",\"expires_at\":\"2030-01-01T00:00:00Z\"}", ) @@ -244,13 +296,6 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { const val AWAIT_SECONDS = 10L const val CLOCK_MILLIS = 1_000_000L val SCOPE = RemoteConfigSnapshotScope("project", "production", "canonical-user") - val BINDING = RemoteConfigFetchBinding( - scope = SCOPE, - expectation = RemoteConfigSnapshotEnvelopeExpectation( - projectId = 42, - environmentUid = "production", - ), - ) val WIRE_BODY = "{\"schema_version\":1,\"project_id\":42,\"environment_uid\":\"production\"," + "\"release_uid\":\"release-1\",\"release_number\":1," + "\"manifest_content_hash\":\"${"1".padStart(64, '0')}\"," + diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportTest.kt index d9b944211..a58ffaf03 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportTest.kt @@ -202,10 +202,13 @@ internal class RemoteConfigGatewayTransportTest { fetch(RemoteConfigFetchRequest(), transport) server.takeRequest() server.takeRequest() + // The session record plus the project id the bootstrap established. val keysAfterFirstIdentity = cache.strings.keys.toSet() - assertEquals(1, keysAfterFirstIdentity.size) - assertFalse(keysAfterFirstIdentity.single().contains(USER_A)) - assertFalse(keysAfterFirstIdentity.single().contains(PROJECT_TOKEN)) + assertEquals(2, keysAfterFirstIdentity.size) + keysAfterFirstIdentity.forEach { key -> + assertFalse(key, key.contains(USER_A)) + assertFalse(key, key.contains(PROJECT_TOKEN)) + } identity = identityFor(SCOPE_B, USER_B) server.enqueue(sessionResponse(OTHER_SESSION_TOKEN)) @@ -217,11 +220,102 @@ internal class RemoteConfigGatewayTransportTest { assertEquals("{\"user_uid\":\"$USER_B\"}", bootstrap.body.readUtf8()) val snapshot = server.takeRequest() assertEquals(OTHER_SESSION_TOKEN, snapshot.getHeader(REMOTE_CONFIG_SESSION_HEADER)) - assertEquals(2, cache.strings.size) + // Two session records, and still ONE project id record: it addresses the project, not the + // identity, so the second identity inherits the pin rather than re-learning it. + assertEquals(3, cache.strings.size) assertEquals(SESSION_TOKEN, store().load(KEY_A)?.token) assertEquals(OTHER_SESSION_TOKEN, store().load(KEY_B)?.token) } + @Test + fun `the bootstrapped project id is published with the snapshot it authorised`() { + server.enqueue(sessionResponse(SESSION_TOKEN, projectId = 77)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + + val success = fetch(RemoteConfigFetchRequest()) as RemoteConfigFetchResponse.Success + + // The admission check has no other source for it: nothing in this transport was configured + // with a project id. + assertEquals(77L, success.projectId) + assertEquals(77L, projectIdStore().load(SCOPE_A)) + } + + @Test + fun `a later bootstrap for a different project is refused instead of re-learned`() { + val transport = transport() + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + assertTrue(fetch(RemoteConfigFetchRequest(), transport) is RemoteConfigFetchResponse.Success) + + // The 401 forces a re-bootstrap, which now answers for another project. + server.enqueue(MockResponse().setResponseCode(401)) + server.enqueue(sessionResponse(OTHER_SESSION_TOKEN, projectId = 43)) + + assertEquals(RemoteConfigFetchResponse.ProjectMismatch, fetch(RemoteConfigFetchRequest(), transport)) + // No snapshot was read on the refused session, and it was not kept either. + assertEquals(4, server.requestCount) + assertNull(store().load(KEY_A)) + assertEquals(PROJECT_ID, projectIdStore().load(SCOPE_A)) + } + + @Test + fun `a persisted session for another project is refused and dropped`() { + assertTrue(projectIdStore().save(SCOPE_A, PROJECT_ID)) + persistSession(KEY_A, SESSION_TOKEN, projectId = 43) + + assertEquals(RemoteConfigFetchResponse.ProjectMismatch, fetch(RemoteConfigFetchRequest())) + assertEquals(0, server.requestCount) + assertNull(store().load(KEY_A)) + } + + @Test + fun `an established project id outlives the registry and store instances that learned it`() { + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + fetch(RemoteConfigFetchRequest()) + + // Brand new registry over a brand new store instance, i.e. what a cold start builds: the + // pin is read back from durable storage rather than re-learned from the next answer. + assertEquals(RemoteConfigProjectIdOutcome.Conflict, registry().establish(SCOPE_A, 43)) + assertEquals(RemoteConfigProjectIdOutcome.Established, registry().establish(SCOPE_A, PROJECT_ID)) + } + + @Test + fun `a pin that could not be persisted still fences this process`() { + val refusingStore = object : RemoteConfigProjectIdStore { + override fun load(scope: RemoteConfigSnapshotScope): Long? = throw IllegalStateException("boom") + override fun save(scope: RemoteConfigSnapshotScope, projectId: Long) = false + } + val registry = RemoteConfigProjectIdRegistry(refusingStore) + val transport = transport(projectIds = registry) + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + assertTrue(fetch(RemoteConfigFetchRequest(), transport) is RemoteConfigFetchResponse.Success) + + // Storage neither kept nor could re-read the pin, and it still cannot be re-learned. + assertEquals(RemoteConfigProjectIdOutcome.Conflict, registry.establish(SCOPE_A, 43)) + assertEquals(RemoteConfigProjectIdOutcome.Established, registry.establish(SCOPE_A, PROJECT_ID)) + } + + @Test + fun `a session without a usable project id is an ordinary failure, not a mismatch`() { + // Malformed, not misrouted: it says nothing about which project this installation reads, + // so neither the registry nor the transport may report the permanent addressing fault. + val registry = registry() + assertEquals(RemoteConfigProjectIdOutcome.Unusable, registry.establish(SCOPE_A, 0)) + assertEquals(RemoteConfigProjectIdOutcome.Established, registry.establish(SCOPE_A, PROJECT_ID)) + + server.enqueue( + MockResponse().setResponseCode(200).setBody( + "{\"session_token\":\"$SESSION_TOKEN\",\"project_id\":0," + + "\"environment\":\"prod\",\"expires_at\":\"2030-01-01T00:00:00Z\"}", + ), + ) + + assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) + assertEquals(1, server.requestCount) + } + @Test fun `a persisted session is reused without another bootstrap until it expires`() { persistSession(KEY_A, SESSION_TOKEN, expiresAtMillis = clock.now + 3_600_000) @@ -434,12 +528,14 @@ internal class RemoteConfigGatewayTransportTest { private fun transport( sessionStore: RemoteConfigSessionStore = store(), maxSnapshotBodyBytes: Long = REMOTE_CONFIG_SNAPSHOT_BODY_MAX_BYTES, + projectIds: RemoteConfigProjectIdRegistry = registry(), ) = RemoteConfigGatewayTransport( callFactory = client, baseUrlProvider = { server.url("/").toString() }, identityProvider = { identity }, clientContextProvider = { clientContext }, sessionStore = sessionStore, + projectIds = projectIds, clock = clock, moshi = Moshi.Builder().build(), logger = logger, @@ -448,17 +544,22 @@ internal class RemoteConfigGatewayTransportTest { private fun store() = PersistentRemoteConfigSessionStore(cache, Moshi.Builder().build()) + private fun projectIdStore() = PersistentRemoteConfigProjectIdStore(cache) + + private fun registry() = RemoteConfigProjectIdRegistry(projectIdStore()) + private fun persistSession( key: RemoteConfigSessionKey, token: String, expiresAtMillis: Long = clock.now + 3_600_000, + projectId: Long = PROJECT_ID, ) { assertTrue( store().save( key, RemoteConfigGatewaySession( token = token, - projectId = 42, + projectId = projectId, environment = "prod", expiresAtMillis = expiresAtMillis, ), @@ -466,11 +567,11 @@ internal class RemoteConfigGatewayTransportTest { ) } - private fun sessionResponse(token: String) = MockResponse() + private fun sessionResponse(token: String, projectId: Long = PROJECT_ID) = MockResponse() .setResponseCode(200) .setHeader("Cache-Control", "private, no-store") .setBody( - "{\"session_token\":\"$token\",\"project_id\":42,\"environment\":\"prod\"," + + "{\"session_token\":\"$token\",\"project_id\":$projectId,\"environment\":\"prod\"," + "\"expires_at\":\"2030-01-01T00:00:00Z\"}", ) @@ -547,6 +648,7 @@ internal class RemoteConfigGatewayTransportTest { const val AWAIT_SECONDS = 10L const val THREADS = 4 const val PROJECT_TOKEN = "project-key-secret" + const val PROJECT_ID = 42L const val SESSION_TOKEN = "qrcs1.session-secret" const val OTHER_SESSION_TOKEN = "qrcs1.other-session-secret" const val USER_A = "QON_anon_a" diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCoreTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCoreTest.kt index e67c84df2..0f7cff2a5 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCoreTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigSnapshotCoreTest.kt @@ -419,8 +419,8 @@ internal class RemoteConfigSnapshotCoreTest { values = "\"good\":${wireItem("1")},\"bad\":${wireItem("{\"x\":1,\"x\":2}")}", ) - val result = core.admitCandidate( - admissionToken = requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + val result = core.admitWire( + admissionToken = requireNotNull(core.beginAdmission(scopeA)), body = malformed.encodeToByteArray(), etag = strongETag(malformed.encodeToByteArray()), ) @@ -434,13 +434,28 @@ internal class RemoteConfigSnapshotCoreTest { fun `wire admission fences exact identity project and environment`() { core.setScope(scopeA) val body = wireBody("wire", 1, "\"a\":${wireItem("1")}").encodeToByteArray() - assertNull(core.beginAdmission(scopeB, wireExpectation())) - assertNull(core.beginAdmission(scopeA, wireExpectation().copy(environmentUid = "staging"))) - val token = requireNotNull(core.beginAdmission(scopeA, wireExpectation().copy(projectId = 43))) + assertNull(core.beginAdmission(scopeB)) + // The environment is the admitting scope's own, so an envelope for another one is refused + // without anyone having to restate it; the project id is the one the session established. + val staging = wireBody("wire", 1, "\"a\":${wireItem("1")}", environmentUid = "staging") + .encodeToByteArray() assertEquals( RemoteConfigSnapshotTransitionStatus.Rejected, - core.admitCandidate(token, body, strongETag(body)).status, + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), + staging, + strongETag(staging), + ).status, + ) + assertEquals( + RemoteConfigSnapshotTransitionStatus.Rejected, + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), + body, + strongETag(body), + projectId = 43, + ).status, ) assertTrue(store.savedStates.isEmpty()) } @@ -456,8 +471,8 @@ internal class RemoteConfigSnapshotCoreTest { values = "\"kept\":${wireItem("2", immediate = true)}", ).encodeToByteArray() - val result = core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + val result = core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), body, strongETag(body), ) @@ -487,10 +502,10 @@ internal class RemoteConfigSnapshotCoreTest { } val raceCore = RemoteConfigSnapshotCore(raceStore, bundled, blockingParser) raceCore.setScope(scopeA) - val admissionToken = requireNotNull(raceCore.beginAdmission(scopeA, expectation)) + val admissionToken = requireNotNull(raceCore.beginAdmission(scopeA)) val result = AtomicReference() val admissionThread = Thread { - result.set(raceCore.admitCandidate(admissionToken, body, etag)) + result.set(raceCore.admitWire(admissionToken, body, etag)) } admissionThread.start() assertTrue(parserStarted.await(2, TimeUnit.SECONDS)) @@ -505,30 +520,33 @@ internal class RemoteConfigSnapshotCoreTest { } @Test - fun `admission token is opaque to another core and carries its original expectation`() { + fun `admission token is opaque to another core and admits only the served project`() { val firstStore = RecordingSnapshotStore() val secondStore = RecordingSnapshotStore() val firstCore = RemoteConfigSnapshotCore(firstStore, bundled) val secondCore = RemoteConfigSnapshotCore(secondStore, bundled) firstCore.setScope(scopeA) secondCore.setScope(scopeA) - val token = requireNotNull(firstCore.beginAdmission(scopeA, wireExpectation())) + val token = requireNotNull(firstCore.beginAdmission(scopeA)) val validBody = wireBody("wire-a", 7, "\"a\":${wireItem("1")}").encodeToByteArray() assertEquals( RemoteConfigSnapshotTransitionStatus.Rejected, - secondCore.admitCandidate(token, validBody, strongETag(validBody)).status, + secondCore.admitWire(token, validBody, strongETag(validBody)).status, ) assertTrue(secondStore.savedStates.isEmpty()) - // The expectation travels with the token: one issued for another project cannot admit this - // project's body, even from the core that issued it and on the scope it was issued for. - val foreignProjectToken = requireNotNull( - firstCore.beginAdmission(scopeA, wireExpectation().copy(projectId = 43)), - ) + // The project the response was served for travels with the response: a body naming another + // project than the session it arrived on cannot be admitted, even by the core and scope the + // token was issued for. assertEquals( RemoteConfigSnapshotTransitionStatus.Rejected, - firstCore.admitCandidate(foreignProjectToken, validBody, strongETag(validBody)).status, + firstCore.admitWire( + requireNotNull(firstCore.beginAdmission(scopeA)), + validBody, + strongETag(validBody), + projectId = 43, + ).status, ) assertTrue(firstStore.savedStates.isEmpty()) } @@ -560,15 +578,15 @@ internal class RemoteConfigSnapshotCoreTest { raceCore.setScope(scopeA) val observed = mutableListOf() raceCore.addUpdateObserver(observed::add) - val superseded = requireNotNull(raceCore.beginAdmission(scopeA, expectation)) + val superseded = requireNotNull(raceCore.beginAdmission(scopeA)) val result = AtomicReference() val admissionThread = Thread { - result.set(raceCore.admitCandidate(superseded, immediateBody, strongETag(immediateBody))) + result.set(raceCore.admitWire(superseded, immediateBody, strongETag(immediateBody))) } admissionThread.start() assertTrue(parserStarted.await(2, TimeUnit.SECONDS)) - requireNotNull(raceCore.beginAdmission(scopeA, expectation)) + requireNotNull(raceCore.beginAdmission(scopeA)) releaseParser.countDown() admissionThread.join(2_000) @@ -614,12 +632,11 @@ internal class RemoteConfigSnapshotCoreTest { 2, "\"a\":${wireItem("2", immediate = true)}", ).encodeToByteArray() - val expectation = wireExpectation() - val supersededToken = requireNotNull(raceCore.beginAdmission(scopeA, expectation)) + val supersededToken = requireNotNull(raceCore.beginAdmission(scopeA)) val supersededResult = AtomicReference() val supersededThread = Thread { supersededResult.set( - raceCore.admitCandidate( + raceCore.admitWire( supersededToken, supersededBody, strongETag(supersededBody), @@ -629,7 +646,7 @@ internal class RemoteConfigSnapshotCoreTest { supersededThread.start() assertTrue(supersededCommitFinished.await(2, TimeUnit.SECONDS)) - requireNotNull(raceCore.beginAdmission(scopeA, expectation)) + requireNotNull(raceCore.beginAdmission(scopeA)) releaseFirstDelivery.countDown() blockingDeliveryThread.join(2_000) supersededThread.join(2_000) @@ -645,8 +662,8 @@ internal class RemoteConfigSnapshotCoreTest { val current = wireBody("release-seven", 7, "\"a\":${wireItem("7")}").encodeToByteArray() assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, - core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), current, strongETag(current), ).status, @@ -655,8 +672,8 @@ internal class RemoteConfigSnapshotCoreTest { assertEquals( RemoteConfigSnapshotTransitionStatus.Rejected, - core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), rollback, strongETag(rollback), ).status, @@ -667,8 +684,8 @@ internal class RemoteConfigSnapshotCoreTest { val secondRollback = wireBody("release-five", 5, "\"a\":${wireItem("5")}").encodeToByteArray() assertEquals( RemoteConfigSnapshotTransitionStatus.Rejected, - core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), secondRollback, strongETag(secondRollback), ).status, @@ -700,8 +717,8 @@ internal class RemoteConfigSnapshotCoreTest { assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, - core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), first, strongETag(first), ).status, @@ -709,8 +726,8 @@ internal class RemoteConfigSnapshotCoreTest { core.activate() assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, - core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), second, strongETag(second), ).status, @@ -724,44 +741,44 @@ internal class RemoteConfigSnapshotCoreTest { @Test fun `request start token rejects old response after new and accepts new response after old`() { core.setScope(scopeA) - val oldToken = requireNotNull(core.beginAdmission(scopeA, wireExpectation())) - val newToken = requireNotNull(core.beginAdmission(scopeA, wireExpectation())) + val oldToken = requireNotNull(core.beginAdmission(scopeA)) + val newToken = requireNotNull(core.beginAdmission(scopeA)) val oldBody = wireBody("old", 7, "\"a\":${wireItem("1")}").encodeToByteArray() val newBody = wireBody("new", 7, "\"a\":${wireItem("2")}").encodeToByteArray() assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, - core.admitCandidate(newToken, newBody, strongETag(newBody)).status, + core.admitWire(newToken, newBody, strongETag(newBody)).status, ) assertEquals( RemoteConfigSnapshotTransitionStatus.Rejected, - core.admitCandidate(oldToken, oldBody, strongETag(oldBody)).status, + core.admitWire(oldToken, oldBody, strongETag(oldBody)).status, ) assertEquals("new", core.lastFetchedSnapshot()?.releaseUid) - val laterToken = requireNotNull(core.beginAdmission(scopeA, wireExpectation())) + val laterToken = requireNotNull(core.beginAdmission(scopeA)) val laterBody = wireBody("later", 7, "\"a\":${wireItem("3")}").encodeToByteArray() assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, - core.admitCandidate(laterToken, laterBody, strongETag(laterBody)).status, + core.admitWire(laterToken, laterBody, strongETag(laterBody)).status, ) assertEquals("later", core.lastFetchedSnapshot()?.releaseUid) val orderedCore = RemoteConfigSnapshotCore(store, bundled) orderedCore.setScope(scopeB) - val orderedOldToken = requireNotNull(orderedCore.beginAdmission(scopeB, wireExpectation())) + val orderedOldToken = requireNotNull(orderedCore.beginAdmission(scopeB)) assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, - orderedCore.admitCandidate( + orderedCore.admitWire( orderedOldToken, oldBody, strongETag(oldBody), ).status, ) - val orderedNewToken = requireNotNull(orderedCore.beginAdmission(scopeB, wireExpectation())) + val orderedNewToken = requireNotNull(orderedCore.beginAdmission(scopeB)) assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, - orderedCore.admitCandidate( + orderedCore.admitWire( orderedNewToken, newBody, strongETag(newBody), @@ -774,20 +791,20 @@ internal class RemoteConfigSnapshotCoreTest { fun `restart restores admission token high water mark`() { core.setScope(scopeA) val body = wireBody("wire", 7, "\"a\":${wireItem("1")}").encodeToByteArray() - val committedToken = requireNotNull(core.beginAdmission(scopeA, wireExpectation())) + val committedToken = requireNotNull(core.beginAdmission(scopeA)) assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, - core.admitCandidate(committedToken, body, strongETag(body)).status, + core.admitWire(committedToken, body, strongETag(body)).status, ) val committedOrdinal = requireNotNull(store.states.getValue(scopeA).candidate).admissionToken val restarted = RemoteConfigSnapshotCore(store, bundled) restarted.setScope(scopeA) - val restartedToken = requireNotNull(restarted.beginAdmission(scopeA, wireExpectation())) + val restartedToken = requireNotNull(restarted.beginAdmission(scopeA)) val restartedBody = wireBody("wire-restarted", 7, "\"a\":${wireItem("2")}").encodeToByteArray() assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, - restarted.admitCandidate(restartedToken, restartedBody, strongETag(restartedBody)).status, + restarted.admitWire(restartedToken, restartedBody, strongETag(restartedBody)).status, ) assertTrue(requireNotNull(store.states.getValue(scopeA).candidate).admissionToken > committedOrdinal) @@ -809,16 +826,16 @@ internal class RemoteConfigSnapshotCoreTest { assertEquals( RemoteConfigSnapshotTransitionStatus.Activated, - core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), first, strongETag(first), ).status, ) assertEquals( RemoteConfigSnapshotTransitionStatus.Activated, - core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation())), + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), second, strongETag(second), ).status, @@ -836,17 +853,30 @@ internal class RemoteConfigSnapshotCoreTest { } private fun wireExpectation() = RemoteConfigSnapshotEnvelopeExpectation( - projectId = 42, + projectId = WIRE_PROJECT_ID, environmentUid = "production", ) + /** + * Admits a body the way the coordinator does: the project id comes from the gateway session the + * response was served on, not from the admission claim. + */ + private fun RemoteConfigSnapshotCore.admitWire( + admissionToken: RemoteConfigSnapshotAdmissionToken, + body: ByteArray, + etag: String, + projectId: Long = WIRE_PROJECT_ID, + ) = admitCandidate(admissionToken, body, etag, projectId) + private fun wireBody( releaseUid: String, releaseNumber: Long, values: String, contextFingerprint: String = "a".repeat(64), + environmentUid: String = "production", + projectId: Long = WIRE_PROJECT_ID, ) = - "{\"schema_version\":1,\"project_id\":42,\"environment_uid\":\"production\"," + + "{\"schema_version\":1,\"project_id\":$projectId,\"environment_uid\":\"$environmentUid\"," + "\"release_uid\":\"$releaseUid\",\"release_number\":$releaseNumber," + "\"manifest_content_hash\":\"${hash(releaseNumber)}\",\"complete_key_set\":true," + "\"context_fingerprint\":\"$contextFingerprint\",\"values\":{$values}}" @@ -914,4 +944,8 @@ internal class RemoteConfigSnapshotCoreTest { return true } } + + private companion object { + const val WIRE_PROJECT_ID = 42L + } } diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt index 1587f30ba..1062d2155 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt @@ -179,6 +179,7 @@ internal class RemoteConfigV2Harness( }, clientContextProvider = clientContextProvider, sessionStore = InMemorySessionStore(), + projectIds = RemoteConfigProjectIdRegistry(InMemoryProjectIdStore()), clock = { System.currentTimeMillis() }, moshi = Moshi.Builder().build(), logger = SilentLogger(), @@ -199,7 +200,7 @@ internal class RemoteConfigV2Harness( core = core, readGuard = readGuard, coordinator = coordinator, - options = RemoteConfigV2Options(RC_PROJECT_KEY, RC_ENVIRONMENT, RC_PROJECT_ID), + options = RemoteConfigV2Options(RC_PROJECT_KEY, RC_ENVIRONMENT), scopeHolder = scopeHolder, scheduler = timeoutScheduler, worker = worker, @@ -442,6 +443,21 @@ internal class InMemorySessionStore : RemoteConfigSessionStore { } } +internal class InMemoryProjectIdStore : RemoteConfigProjectIdStore { + private val projectIds = mutableMapOf, Long>() + + @Synchronized + override fun load(scope: RemoteConfigSnapshotScope): Long? = projectIds[key(scope)] + + @Synchronized + override fun save(scope: RemoteConfigSnapshotScope, projectId: Long): Boolean { + projectIds[key(scope)] = projectId + return true + } + + private fun key(scope: RemoteConfigSnapshotScope) = scope.projectKey to scope.environment +} + internal class SilentLogger : Logger { override fun error(message: String) = Unit override fun warn(message: String) = Unit diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStoreTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStoreTest.kt index b599aee6b..fa0b72055 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStoreTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStoreTest.kt @@ -506,15 +506,7 @@ internal class PersistentRemoteConfigSnapshotStoreTest { assertEquals(7L, recovered?.latestAdmissionToken) val restartedCore = RemoteConfigSnapshotCore(store(), bundledRelease = null) restartedCore.setScope(userA) - assertNotNull( - restartedCore.beginAdmission( - userA, - RemoteConfigSnapshotEnvelopeExpectation( - projectId = 42, - environmentUid = "production", - ), - ), - ) + assertNotNull(restartedCore.beginAdmission(userA)) } @Test