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..cb66d29a3 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/remoteconfig/QRemoteConfigV2Config.kt @@ -0,0 +1,53 @@ +package com.qonversion.android.sdk.dto.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi + +private const val REMOTE_CONFIG_V2_UID_MAX_CODE_POINTS = 36 + +/** + * Enables the experimental Remote Config v2 snapshot pipeline. + * + * The pipeline is **dormant** unless this configuration is passed to + * `QonversionConfig.Builder.setRemoteConfigV2Config`: without it the SDK builds no v2 store, opens + * no v2 connection, and `QRemoteConfigSnapshots` answers every fetch with + * [QRemoteConfigFetchStatus.NotConfigured] while still serving bundled defaults. There is no + * default base URL and no production endpoint is contacted implicitly. + * + * The targeting context a snapshot was resolved for is deliberately **not** configured here, and no + * future version will ask for it. The fingerprint hashes mutable targeting context (app/OS version, + * locale, purchases, properties); it rotates legitimately and MUST NOT be pinned across fetches. + * Identity isolation is the session's job: every snapshot read travels on a session token minted + * for exactly one identity, the gateway routes on that session, and the SDK stores each identity's + * releases under its own scoped storage key. + * + * The numeric project id is deliberately **not** configured here either, although a served snapshot + * is checked against one. Unlike the fingerprint it is stable, but the app is not its source: the + * SDK learns it from the gateway's session bootstrap, pins the first value it is ever told, and + * treats a later bootstrap that answers with a different one as a hard failure. + * + * That is a trade, not a strict improvement: the check no longer proves a snapshot belongs to the + * project the developer meant to target — the first bootstrap is trusted — it proves that every + * snapshot and every later session agree with the first one. What it buys is that a value the app + * could only ever get wrong is gone, and the property that actually protects a user — a snapshot + * being served for the session that asked for it — is enforced against the server's own answer. + * + * @param baseUrl base URL of the Remote Config v2 gateway, e.g. `https://host/`. The SDK appends + * its own paths, so a bare origin is expected. + * @param environmentUid uid of the Remote Config environment to read. + * @throws IllegalArgumentException if any value is malformed. + */ +@ExperimentalQonversionApi +class QRemoteConfigV2Config( + val baseUrl: String, + val environmentUid: 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" } + } +} 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/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/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/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/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 b1e43fe3c..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 @@ -22,10 +22,24 @@ 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 environment the SDK was + * configured for and the project its gateway session was minted for. + * + * [projectId] is learned, not configured: the session bootstrap is the SDK's only source for it, + * the first bootstrap of a scope pins it, and a later disagreement is refused before a snapshot is + * ever read (see [RemoteConfigProjectIdRegistry]). + * + * The targeting context is deliberately absent. The fingerprint hashes mutable targeting context + * (app/OS version, locale, purchases, properties); it rotates legitimately and MUST NOT be pinned + * across fetches. Identity isolation is the session's job — the snapshot read travels on a session + * token minted for one identity and the gateway routes on it — so the parser validates the + * fingerprint's *shape* and carries it through as an opaque per-response tag, and nothing anywhere + * compares it against a previous response's value. + */ internal data class RemoteConfigSnapshotEnvelopeExpectation( val projectId: Long, val environmentUid: String, - val contextFingerprint: String, ) internal class RemoteConfigSnapshotEnvelope internal constructor( @@ -58,8 +72,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 +488,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 new file mode 100644 index 000000000..a23a11d44 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2Factory.kt @@ -0,0 +1,191 @@ +@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 scope transition is observed) is + // exactly this executor's FIFO ordering. + val worker = Executors.newSingleThreadExecutor(daemonThreadFactory(REMOTE_CONFIG_V2_WORKER_THREAD_NAME)) + val scheduler = scheduler() + val readGuard = RemoteConfigReadGuard( + core = core, + preloader = PersistentRemoteConfigReadPreloader(store, worker), + buildMode = if (application.isDebuggable) { + RemoteConfigReadBuildMode.Debug + } else { + RemoteConfigReadBuildMode.Release + }, + assertion = { message -> + logger.error(message) + // Only fires when JVM assertions are enabled, so a debug build shouts without + // turning a config read into a production crash. + assert(false) { message } + }, + telemetry = { event -> logger.debug("Remote Config v2 guard event: $event") }, + ) + val scopeHolder = RemoteConfigV2ScopeHolder() + val clock = RemoteConfigFetchClock { System.currentTimeMillis() } + val 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, + ), + 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), + // 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, + ) + + 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..f79fdc559 --- /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. + * + * The server-resolved targeting context is deliberately not part of it. The fingerprint hashes + * mutable targeting context (app/OS version, locale, purchases, properties); it rotates legitimately + * and MUST NOT be pinned across fetches. Identity isolation is the session's job — see + * [RemoteConfigGatewaySession] and the per-scope storage keys. + * + * The numeric project id is not part of it either: the SDK learns it from the session bootstrap + * rather than from the app, and it travels with the response it addresses. + */ +internal data class RemoteConfigV2Options( + val projectKey: String, + val environmentUid: String, +) + +/** + * The identity scope the transport addresses, published for the transport's identity provider. + * + * The transport is constructed before any identity is known, so it reads the scope through this + * holder instead of capturing one. + */ +internal class RemoteConfigV2ScopeHolder { + private val current = AtomicReference(null) + + 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 observes the new 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 submitted = submit { + coordinator.transitionTo(scope) + if (scope != null) forceFetch(forceReason) + } + if (!submitted) logger.debug("Remote Config v2 could not apply an identity change") + } + + /** + * Re-reads targeting for the *same* identity, e.g. after user properties or an attached + * experiment changed the evaluation inputs. + * + * Deliberately not a scope transition: the identity did not change, so the served release must + * keep serving until a newer one is fetched and activated. + */ + fun refreshTargeting() { + 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 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) + // A permanent addressing fault the transport has already reported: no snapshot was read at + // all, so there is nothing to report beyond the failure itself. + RemoteConfigFetchResult.ProjectMismatch -> result(QRemoteConfigFetchStatus.Failed) + } + + private fun RemoteConfigSnapshotTransitionResult.toFetchStatus(): QRemoteConfigFetchStatus = when (status) { + // Rejected covers a malformed envelope AND a snapshot addressed to another project or + // environment than the session it was served for. The latter is a permanent server-side + // fault that otherwise looks exactly like a network failure. A changed targeting context is + // NOT in this class: it rotates on any app/OS update, locale change, purchase or property + // edit. + RemoteConfigSnapshotTransitionStatus.Accepted, + RemoteConfigSnapshotTransitionStatus.Activated, + RemoteConfigSnapshotTransitionStatus.Unchanged, + -> QRemoteConfigFetchStatus.Fetched + RemoteConfigSnapshotTransitionStatus.Ignored -> QRemoteConfigFetchStatus.Superseded + RemoteConfigSnapshotTransitionStatus.PersistenceFailed -> QRemoteConfigFetchStatus.Failed + RemoteConfigSnapshotTransitionStatus.Rejected -> { + logger.error( + "Remote Config v2 refused a snapshot: it did not match the project id or " + + "environment uid of the session it was served for, or the envelope was malformed", + ) + QRemoteConfigFetchStatus.Failed + } + } + + private fun submit(action: () -> Unit): Boolean = try { + worker.execute { + try { + action() + } catch (@Suppress("TooGenericExceptionCaught") error: RuntimeException) { + logger.debug("Remote Config v2 background work failed: ${error.javaClass.simpleName}") + } + } + true + } catch (@Suppress("TooGenericExceptionCaught") _: RuntimeException) { + false + } + + private fun RemoteConfigFetchScheduledTask?.cancelSafely() { + try { + this?.cancel() + } catch (@Suppress("TooGenericExceptionCaught") _: RuntimeException) { + // Single-delivery is enforced independently of best-effort timer cancellation. + } + } + + private inner class SingleDelivery(private val callback: (T) -> Unit) { + private val delivered = AtomicBoolean(false) + + fun deliver(value: T) { + if (!delivered.compareAndSet(false, true)) return + mainDispatcher.post { + try { + callback(value) + } catch (@Suppress("TooGenericExceptionCaught") error: RuntimeException) { + logger.debug("Remote Config v2 callback threw: ${error.javaClass.simpleName}") + } + } + } + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/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..bc215250d --- /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.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Test + +/** + * The v2 configuration is rejected at construction rather than at `build()`: it carries values the + * app cannot invent, so failing at the line that supplies them is what makes the mistake findable. + */ +internal class QRemoteConfigV2ConfigTest { + + @Test + fun `a well formed configuration is accepted verbatim`() { + val config = QRemoteConfigV2Config( + baseUrl = "https://gateway.example.com/", + environmentUid = "production", + ) + + assertEquals("https://gateway.example.com/", config.baseUrl) + assertEquals("production", config.environmentUid) + } + + @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)) }, + ) + + malformed.forEach { (name, build) -> + assertThrows(name, IllegalArgumentException::class.java) { build() } + } + } + + @Test + fun `the configuration neither takes nor exposes a project id`() { + // The numeric project id is learned from the gateway session bootstrap. Re-introducing it + // here would put a value the app cannot verify back into the public surface. Asserted by + // name rather than by shape, so an unrelated field of the same type does not fail this. + val members = QRemoteConfigV2Config::class.java.declaredFields.map { it.name } + + QRemoteConfigV2Config::class.java.declaredMethods.map { it.name } + members.forEach { name -> assertFalse(name, name.contains("rojectId")) } + assertEquals( + setOf("baseUrl", "environmentUid"), + QRemoteConfigV2Config::class.java.declaredFields.map { it.name }.toSet(), + ) + } + + private fun config( + baseUrl: String = "https://gateway.example.com/", + environmentUid: String = "production", + ) = QRemoteConfigV2Config(baseUrl, environmentUid) +} 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..72760a9f5 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/QRemoteConfigsPublicApiTest.kt @@ -0,0 +1,563 @@ +@file:OptIn(ExperimentalQonversionApi::class) + +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.ExperimentalQonversionApi +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigActivationResult +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigApplyPolicy +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigDecoder +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchResult +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigFetchStatus +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigSource +import com.qonversion.android.sdk.dto.remoteconfig.QRemoteConfigUpdate +import com.qonversion.android.sdk.listeners.QonversionRemoteConfigActivationCallback +import com.qonversion.android.sdk.listeners.QonversionRemoteConfigFetchCallback +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * Contract tests for the public Remote Config v2 surface, driven end to end: real snapshot core, + * real read guard, real fetch coordinator, real HTTP. + */ +internal class QRemoteConfigsPublicApiTest { + private val harnesses = mutableListOf() + + @After + fun tearDown() { + harnesses.forEach { it.shutdown() } + } + + @Test + fun `fetch times out with the best available snapshot while the request keeps running`() { + val harness = harness() + harness.delaySnapshotReads(RESPONSE_DELAY_MILLIS) + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + + val latch = CountDownLatch(1) + val result = AtomicReference() + harness.manager.fetch(FETCH_TIMEOUT_MILLIS) { fetchResult -> + result.set(fetchResult) + latch.countDown() + } + awaitScheduledTimeout(harness) + + assertTrue("timeout was not delivered", latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(listOf(FETCH_TIMEOUT_MILLIS), harness.timeoutScheduler.requestedDelays) + val timedOut = requireNotNull(result.get()) + assertEquals(QRemoteConfigFetchStatus.TimedOut, timedOut.status) + // Best available at timeout time: nothing was admitted yet, so the ladder is at fallback. + val fallback = requireNotNull(timedOut.snapshot.rawValue("count")) + assertEquals(QRemoteConfigSource.Fallback, fallback.source) + assertEquals("0", fallback.value) + + // The request was not cancelled — its release is still admitted and activates normally. + harness.awaitCandidate(releaseNumber = 1) + assertTrue(harness.activateBlocking().changed) + assertEquals("1", harness.configs.current.rawValue("count")?.value) + } + + @Test + fun `fetch without an explicit timeout uses the configured default`() { + val harness = harness(defaultFetchTimeoutMillis = FETCH_TIMEOUT_MILLIS) + harness.hangSnapshotReads(true) + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + + val latch = CountDownLatch(1) + val result = AtomicReference() + harness.manager.fetch(null) { fetchResult -> + result.set(fetchResult) + latch.countDown() + } + awaitScheduledTimeout(harness) + + assertTrue(latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(QRemoteConfigFetchStatus.TimedOut, requireNotNull(result.get()).status) + // The default is what was scheduled — a hard-coded or ignored timeout would show up here. + assertEquals(listOf(FETCH_TIMEOUT_MILLIS), harness.timeoutScheduler.requestedDelays) + } + + @Test + fun `activate reports a change only when the activated release differs`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + + assertEquals(QRemoteConfigFetchStatus.Fetched, harness.fetchBlocking().status) + val first = harness.activateBlocking() + val second = harness.activateBlocking() + + assertTrue("the first activation must be a change", first.changed) + assertFalse("re-activating the same release changes nothing", second.changed) + assertEquals("release-1", first.snapshot.releaseUid) + assertNull(first.fetchStatus) + } + + @Test + fun `fetchAndActivate carries the fetch status into the activation result`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + + val latch = CountDownLatch(1) + val result = AtomicReference() + // Through the public facade: this overload must not delegate to itself. + harness.configs.fetchAndActivate { activation -> + result.set(activation) + latch.countDown() + } + + assertTrue(latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + val activation = requireNotNull(result.get()) + assertEquals(QRemoteConfigFetchStatus.Fetched, activation.fetchStatus) + assertTrue(activation.changed) + assertEquals("1", activation.snapshot.rawValue("count")?.value) + } + + @Test + fun `every public fetch and activate overload completes exactly once`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + + val fetched = awaitFetch(harness) { callback -> harness.configs.fetch(callback) } + assertEquals(QRemoteConfigFetchStatus.Fetched, fetched.status) + val fetchedWithTimeout = awaitFetch(harness) { callback -> + harness.configs.fetch(TIMEOUT_UNUSED, callback) + } + assertEquals(QRemoteConfigFetchStatus.Fetched, fetchedWithTimeout.status) + + val activated = awaitActivation(harness) { callback -> harness.configs.activate(callback) } + assertTrue(activated.changed) + assertNull(activated.fetchStatus) + val reActivated = awaitActivation(harness) { callback -> + harness.configs.fetchAndActivate(TIMEOUT_UNUSED, callback) + } + assertFalse(reActivated.changed) + assertEquals(QRemoteConfigFetchStatus.Fetched, reActivated.fetchStatus) + } + + @Test + fun `a server error is reported as a failed fetch`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.serveStatus(HTTP_SERVER_ERROR) + + assertEquals(QRemoteConfigFetchStatus.Failed, harness.fetchBlocking().status) + } + + @Test + fun `an unchanged release is reported as not modified`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.serveStatus(HTTP_NOT_MODIFIED) + + assertEquals(QRemoteConfigFetchStatus.NotModified, harness.fetchBlocking().status) + } + + @Test + fun `a fetch inside the minimum interval is throttled`() { + val harness = RemoteConfigV2Harness(minimumFetchIntervalMillis = THROTTLE_INTERVAL_MILLIS) + .also { harnesses += it } + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + + assertEquals(QRemoteConfigFetchStatus.Throttled, harness.fetchBlocking().status) + } + + @Test + fun `a rotated targeting context keeps being served, it is not an identity signal`() { + // The gateway recomputes the fingerprint from mutable inputs — app/OS version, locale, + // purchases, properties, experiment enrollment — so it changes for the same identity all + // the time. Refusing the new value would freeze this user's config until logout. + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + assertEquals(QRemoteConfigFetchStatus.Fetched, harness.fetchBlocking().status) + harness.activateBlocking() + + harness.rotateContextFingerprint() + + assertEquals(QRemoteConfigFetchStatus.Fetched, harness.fetchBlocking().status) + harness.activateBlocking() + val served = requireNotNull(harness.configs.current.rawValue("count")) + assertEquals(QRemoteConfigSource.Server, served.source) + assertEquals("2", served.value) + assertEquals("release-rotated", harness.core.lastFetchedSnapshot()?.releaseUid) + } + + @Test + fun `a key without metadata reports no metadata rather than the JSON literal`() { + val harness = harness() + harness.serve( + "release-1", + 1, + listOf( + RcWireValue("count", "1"), + RcWireValue("annotated", "2", metadata = "{\"reload\":true}"), + ), + ) + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + + val snapshot = harness.configs.current + assertNull(snapshot.rawValue("count")?.metadataJson) + assertEquals("{\"reload\":true}", snapshot.rawValue("annotated")?.metadataJson) + } + + @Test + fun `a decoder that throws rejects the value like one that returns null`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + + val thrown = harness.configs.current.value("count") { error("decoder blew up") } + + // No prior release to fall back to and no bundled value the decoder accepts, so the read + // resolves to nothing instead of propagating the failure to the caller. + assertNull(thrown) + } + + @Test + fun `reads report every ladder position with its value`() { + val harness = harness() + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + + val server = requireNotNull(harness.configs.current.value("count", INT_DECODER)) + assertEquals(QRemoteConfigSource.Server, server.source) + assertEquals(1, server.value) + + val fallback = requireNotNull(harness.configs.current.value("bundled_only", STRING_DECODER)) + assertEquals(QRemoteConfigSource.Fallback, fallback.source) + assertEquals("\"bundled\"", fallback.value) + + // A release whose value the caller's decoder rejects falls back to the previously + // activated one — that is the cache position, and only a typed read can observe it. + harness.serve("release-2", 2, listOf(RcWireValue("count", "\"not-a-number\""))) + harness.fetchBlocking() + harness.activateBlocking() + + val cached = requireNotNull(harness.configs.current.value("count", INT_DECODER)) + assertEquals(QRemoteConfigSource.Cache, cached.source) + assertEquals(1, cached.value) + } + + @Test + fun `raw reads stay opaque while typed reads apply the decoder`() { + val harness = harness() + harness.serve("release-1", 1, listOf(RcWireValue("count", "{\"nested\":[1,2]}"))) + harness.identify("QON_anon_a", "canonical-a", RemoteConfigFetchForceReason.Build) + harness.fetchBlocking() + harness.activateBlocking() + + val snapshot = harness.configs.current + val raw = requireNotNull(snapshot.rawValue("count")) + assertEquals("{\"nested\":[1,2]}", raw.value) + assertEquals(QRemoteConfigSource.Server, raw.source) + assertEquals(QRemoteConfigApplyPolicy.OnNextActivate, raw.applyPolicy) + assertEquals("var-count-on_next_activate", raw.variationUid) + + val json = requireNotNull(snapshot.jsonValue("count")) + @Suppress("UNCHECKED_CAST") + val nested = (json.value as Map)["nested"] as List + assertEquals(listOf(1.0, 2.0), nested) + + val typed = requireNotNull(snapshot.value("count", QRemoteConfigDecoder { rawJson -> rawJson.length })) + assertEquals("{\"nested\":[1,2]}".length, typed.value) + assertNull(snapshot.rawValue("unknown-key")) + assertEquals(setOf("count", "bundled_only"), snapshot.contextKeys) + } + + @Test + fun `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..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,20 +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", - contextFingerprint = "a".repeat(64), - ), - ) @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) @@ -50,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)) @@ -74,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)) @@ -85,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) @@ -109,16 +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"), - expectation = binding.expectation.copy(contextFingerprint = "b".repeat(64)), - ), - ) + coordinator.transitionTo(RemoteConfigSnapshotScope("project", "production", "identified-user")) val result = mutableListOf() coordinator.fetch(forceReason = RemoteConfigFetchForceReason.Identify, callback = result::add) @@ -143,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( @@ -189,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() @@ -216,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) @@ -248,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) @@ -263,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) @@ -282,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() @@ -298,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) @@ -319,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) @@ -339,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(contextFingerprint = "b".repeat(64)), - ) - 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-context", 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) @@ -392,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" } @@ -400,13 +386,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) @@ -418,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) @@ -432,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 -> @@ -451,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) @@ -478,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)) @@ -490,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) } @@ -498,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) @@ -509,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)) @@ -537,7 +529,7 @@ internal class RemoteConfigFetchCoordinatorTest { maximumBackoffMillis = 10_000, ), ) - coordinator.transitionTo(binding) + coordinator.transitionTo(scope) val result = mutableListOf() coordinator.fetch(callback = result::add) @@ -570,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( @@ -603,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 7156bbca5..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,14 +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", - contextFingerprint = "a".repeat(64), - ), - ) val WIRE_BODY = "{\"schema_version\":1,\"project_id\":42,\"environment_uid\":\"production\"," + "\"release_uid\":\"release-1\",\"release_number\":1," + "\"manifest_content_hash\":\"${"1".padStart(64, '0')}\"," + 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 6b0e0c55c..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()), ) @@ -431,19 +431,32 @@ 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) - } + assertNull(core.beginAdmission(scopeB)) + + // The environment is the admitting scope's own, so an envelope for another one is refused + // without anyone having to restate it; the project id is the one the session established. + val staging = wireBody("wire", 1, "\"a\":${wireItem("1")}", environmentUid = "staging") + .encodeToByteArray() + assertEquals( + RemoteConfigSnapshotTransitionStatus.Rejected, + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), + staging, + strongETag(staging), + ).status, + ) + assertEquals( + RemoteConfigSnapshotTransitionStatus.Rejected, + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), + body, + strongETag(body), + projectId = 43, + ).status, + ) assertTrue(store.savedStates.isEmpty()) } @@ -458,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), ) @@ -489,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)) @@ -507,31 +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()) - val swappedContextBody = wireBody( - "wire-b", - 7, - "\"a\":${wireItem("2")}", - contextFingerprint = "b".repeat(64), - ).encodeToByteArray() + // 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(token, swappedContextBody, strongETag(swappedContextBody)).status, + firstCore.admitWire( + requireNotNull(firstCore.beginAdmission(scopeA)), + validBody, + strongETag(validBody), + projectId = 43, + ).status, ) assertTrue(firstStore.savedStates.isEmpty()) } @@ -563,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) @@ -617,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), @@ -632,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) @@ -648,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, @@ -658,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, @@ -670,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, @@ -680,7 +694,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) @@ -699,8 +717,8 @@ internal class RemoteConfigSnapshotCoreTest { assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, - core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation(firstContext))), + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), first, strongETag(first), ).status, @@ -708,8 +726,8 @@ internal class RemoteConfigSnapshotCoreTest { core.activate() assertEquals( RemoteConfigSnapshotTransitionStatus.Accepted, - core.admitCandidate( - requireNotNull(core.beginAdmission(scopeA, wireExpectation(secondContext))), + core.admitWire( + requireNotNull(core.beginAdmission(scopeA)), second, strongETag(second), ).status, @@ -723,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), @@ -773,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) @@ -808,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, @@ -834,20 +852,31 @@ internal class RemoteConfigSnapshotCoreTest { assertTrue(requireNotNull(saved.active).admissionToken > requireNotNull(saved.previous).admissionToken) } - private fun wireExpectation(contextFingerprint: String = "a".repeat(64)) = - RemoteConfigSnapshotEnvelopeExpectation( - projectId = 42, + private fun wireExpectation() = RemoteConfigSnapshotEnvelopeExpectation( + projectId = WIRE_PROJECT_ID, environmentUid = "production", - contextFingerprint = contextFingerprint, ) + /** + * 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}}" @@ -915,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/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/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..1062d2155 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigV2TestHarness.kt @@ -0,0 +1,466 @@ +@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(), + projectIds = RemoteConfigProjectIdRegistry(InMemoryProjectIdStore()), + 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), + 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) + + /** + * Rotates the targeting context the gateway reports, as it does for real when the app version, + * locale, purchases, properties or experiment enrollment change. + */ + fun rotateContextFingerprint() { + contextFingerprint.set("b".repeat(RC_FINGERPRINT_LENGTH)) + body.set( + rcWireBody( + releaseUid = "release-rotated", + releaseNumber = ROTATED_RELEASE_NUMBER, + values = listOf(RcWireValue("count", "2")), + contextFingerprint = contextFingerprint.get(), + ), + ) + } + + /** Makes the gateway stop answering snapshot reads, without closing the socket. */ + fun hangSnapshotReads(hanging: Boolean) = hang.set(hanging) + + /** Delays the snapshot answer, so a caller-side timeout can win the race deterministically. */ + fun delaySnapshotReads(millis: Long) = responseDelayMillis.set(millis) + + fun identify(userUid: String, canonicalUserId: String, reason: RemoteConfigFetchForceReason) { + this.userUid = userUid + manager.updateIdentity(canonicalUserId, reason) + } + + fun fetchBlocking(timeoutMs: Long? = null): QRemoteConfigFetchResult { + val latch = CountDownLatch(1) + val result = AtomicReference() + val thread = AtomicReference() + manager.fetch(timeoutMs) { fetchResult -> + thread.set(Thread.currentThread().name) + result.set(fetchResult) + latch.countDown() + } + assertTrue("fetch did not complete", latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(RC_MAIN_THREAD_NAME, thread.get()) + return requireNotNull(result.get()) + } + + fun activateBlocking(): QRemoteConfigActivationResult { + val latch = CountDownLatch(1) + val result = AtomicReference() + val thread = AtomicReference() + manager.activate { activationResult -> + thread.set(Thread.currentThread().name) + result.set(activationResult) + latch.countDown() + } + assertTrue("activation did not complete", latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(RC_MAIN_THREAD_NAME, thread.get()) + return requireNotNull(result.get()) + } + + fun subscribeCollecting(updates: MutableList, latch: CountDownLatch) = + manager.subscribeOnConfigUpdate { update -> + updates += update + latch.countDown() + } + + /** Waits until [releaseNumber] is durably admitted as the fetched candidate. */ + fun awaitCandidate(releaseNumber: Long) { + val deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(RC_AWAIT_SECONDS) + while (System.currentTimeMillis() < deadline) { + if (core.lastFetchedSnapshot()?.releaseNumber == releaseNumber) return + Thread.sleep(POLL_INTERVAL_MILLIS) + } + throw AssertionError("release $releaseNumber was never admitted") + } + + /** Blocks until every task already queued on the background worker has run. */ + fun awaitWorkerIdle() { + val latch = CountDownLatch(1) + worker.execute { latch.countDown() } + assertTrue("worker did not drain", latch.await(RC_AWAIT_SECONDS, TimeUnit.SECONDS)) + } + + /** + * Reads the same bundled entries the snapshot core resolves against, so the "manual fallback + * getter" and the fallback rung of the ladder can never silently drift apart. + */ + private fun fallbackRemoteConfigValue(contextKey: String): QRemoteConfigFallbackValue? { + val raw = bundledEntries?.entry(contextKey)?.rawValueBytes ?: return null + return QRemoteConfigFallbackValue( + requireNotNull(decodePortableRemoteConfigJson(raw)).value, + ) + } + + private fun defaultBody(fingerprint: String = RC_FINGERPRINT) = + rcWireBody("release-1", 1, listOf(RcWireValue("count", "1")), fingerprint) + + private fun sessionResponse() = MockResponse() + .setResponseCode(200) + .setBody( + "{\"session_token\":\"qrcs1.session-${sessionRequests.size}\",\"project_id\":$RC_PROJECT_ID," + + "\"environment\":\"prod\",\"expires_at\":\"2030-01-01T00:00:00Z\"}", + ) + + private fun snapshotResponse(): MockResponse { + if (hang.get()) return MockResponse().setSocketPolicy(okhttp3.mockwebserver.SocketPolicy.NO_RESPONSE) + val statusCode = snapshotStatusCode.get() + if (statusCode != HTTP_OK) { + val response = MockResponse().setResponseCode(statusCode) + return if (statusCode == HTTP_NOT_MODIFIED) { + response.setHeader("ETag", rcStrongETag(body.get().toByteArray(Charsets.UTF_8))) + } else { + response + } + } + val bytes = body.get().toByteArray(Charsets.UTF_8) + return MockResponse() + .setResponseCode(200) + .setHeader("ETag", rcStrongETag(bytes)) + .setBody(Buffer().write(bytes)) + .setBodyDelay(responseDelayMillis.get(), TimeUnit.MILLISECONDS) + } + + private companion object { + const val POLL_INTERVAL_MILLIS = 20L + const val ROTATED_RELEASE_NUMBER = 2L + } +} + +internal class ManualScheduler : RemoteConfigFetchScheduler { + private val tasks = mutableListOf() + + /** Every delay the code under test asked for, in scheduling order. */ + val requestedDelays: MutableList = Collections.synchronizedList(mutableListOf()) + + override fun schedule(delayMillis: Long, action: () -> Unit): RemoteConfigFetchScheduledTask { + val task = Task(action) + synchronized(tasks) { tasks += task } + requestedDelays += delayMillis + return RemoteConfigFetchScheduledTask { task.cancelled = true } + } + + /** Fires every scheduled task that has not been cancelled yet. */ + fun runAll() { + val pending = synchronized(tasks) { tasks.toList().also { tasks.clear() } } + pending.forEach { task -> if (!task.cancelled) task.action() } + } + + fun pendingCount(): Int = synchronized(tasks) { tasks.count { !it.cancelled } } + + private class Task(val action: () -> Unit, @Volatile var cancelled: Boolean = false) +} + +internal class InMemorySnapshotStore : RemoteConfigSnapshotStore { + val states = mutableMapOf() + + @Synchronized + override fun load(scope: RemoteConfigSnapshotScope): RemoteConfigSnapshotLoadResult = + states[scope]?.let { RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Found, it) } + ?: RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Missing) + + @Synchronized + override fun save(scope: RemoteConfigSnapshotScope, state: RemoteConfigSnapshotState): Boolean { + states[scope] = state + return true + } +} + +internal class InMemoryFetchPolicyStore : RemoteConfigFetchPolicyStore { + private val states = mutableMapOf() + + @Synchronized + override fun load(scope: RemoteConfigFetchPolicyScope) = states[scope] + + @Synchronized + override fun save(scope: RemoteConfigFetchPolicyScope, state: RemoteConfigFetchPolicyState): Boolean { + states[scope] = state + return true + } +} + +internal class InMemorySessionStore : RemoteConfigSessionStore { + private val sessions = mutableMapOf() + + @Synchronized + override fun load(key: RemoteConfigSessionKey) = sessions[key] + + @Synchronized + override fun save(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession): Boolean { + sessions[key] = session + return true + } + + @Synchronized + override fun clear(key: RemoteConfigSessionKey): Boolean { + sessions.remove(key) + return true + } +} + +internal class InMemoryProjectIdStore : RemoteConfigProjectIdStore { + private val projectIds = mutableMapOf, Long>() + + @Synchronized + override fun load(scope: RemoteConfigSnapshotScope): Long? = projectIds[key(scope)] + + @Synchronized + override fun save(scope: RemoteConfigSnapshotScope, projectId: Long): Boolean { + projectIds[key(scope)] = projectId + return true + } + + private fun key(scope: RemoteConfigSnapshotScope) = scope.projectKey to scope.environment +} + +internal class SilentLogger : Logger { + override fun error(message: String) = Unit + override fun warn(message: String) = Unit + override fun release(message: String) = Unit + override fun debug(message: String) = Unit +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStoreTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigSnapshotStoreTest.kt index e6746b6a2..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,16 +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", - contextFingerprint = "b".repeat(64), - ), - ), - ) + assertNotNull(restartedCore.beginAdmission(userA)) } @Test @@ -694,7 +685,6 @@ internal class PersistentRemoteConfigSnapshotStoreTest { expectation = RemoteConfigSnapshotEnvelopeExpectation( projectId = 42, environmentUid = "production", - contextFingerprint = "b".repeat(64), ), ), ).release