Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 17 additions & 0 deletions sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 24 additions & 2 deletions sdk/src/main/java/com/qonversion/android/sdk/QonversionConfig.kt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:OptIn(ExperimentalQonversionApi::class)

package com.qonversion.android.sdk

import android.app.Application
Expand All @@ -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
Expand All @@ -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
) {

/**
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -186,7 +207,8 @@ class QonversionConfig internal constructor(
context.application,
primaryConfig,
cacheConfig,
deferredPurchasesListener
deferredPurchasesListener,
remoteConfigV2Config
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
@@ -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<out T> {
/**
* @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?
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,
}
Loading
Loading