diff --git a/sdk/build.gradle b/sdk/build.gradle index 1a0594a6..c095bddb 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -84,7 +84,11 @@ ext { network = [ core : "com.squareup.retrofit2:retrofit:$retrofit_version", moshiConverter : "com.squareup.retrofit2:converter-moshi:$retrofit_version", - okhttp : "com.squareup.okhttp3:okhttp:$okhttp_version" + okhttp : "com.squareup.okhttp3:okhttp:$okhttp_version", + // Retrofit 2.9.0 pulls okhttp 3.14.9, which wins conflict resolution over + // okhttp_version above; MockWebServer touches okhttp3.internal.* so it must + // match the version that actually resolves, not the one declared. + mockWebServer : "com.squareup.okhttp3:mockwebserver:3.14.9" ] lifecycle = [ @@ -164,6 +168,9 @@ dependencies { // Mockito testImplementation 'org.mockito:mockito-core:4.3.1' + // MockWebServer (HTTP contract tests) + testImplementation network.mockWebServer + testImplementation 'androidx.test:core:1.5.0' testImplementation 'androidx.test.ext:junit:1.1.5' testImplementation "org.json:json:20180813" diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/DeviceRemoteConfigClientContextProvider.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/DeviceRemoteConfigClientContextProvider.kt new file mode 100644 index 00000000..9794f8f5 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/DeviceRemoteConfigClientContextProvider.kt @@ -0,0 +1,74 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import java.util.Locale + +private const val ANDROID_PLATFORM = "android" +private const val UNKNOWN = "UNKNOWN" +private const val UNDETERMINED_LANGUAGE_TAG = "und" +private const val MILLIS_IN_SECOND = 1_000L + +/** + * Builds the snapshot request's `client_context` from device facts only. + * + * The constructor takes no identity on purpose. `device_installed_at` is read from + * `PackageManager.firstInstallTime`, which is a property of the installed package on this device: + * it is untouched by `identify()`, by logout, and by the anonymous uid being re-minted. That is + * exactly the invariant the server relies on — it evaluates account age as + * `min(device_installed_at, client.created_at)`, so a post-logout client row looks brand new and + * only the preserved device install date keeps a long-standing user out of "new users" targeting. + * + * Reusing the SDK's existing install-date source (`QProductCenterManager` reads the same + * `firstInstallTime` for `install_date`) keeps a single notion of "when this device installed the + * app" across the wire. + */ +internal class DeviceRemoteConfigClientContextProvider( + private val context: Context, + private val sdkVersion: String, +) : RemoteConfigClientContextProvider { + + override fun clientContext(): RemoteConfigClientContext? { + val packageInfo = packageInfo() ?: return null + return RemoteConfigClientContext( + platform = ANDROID_PLATFORM, + appVersion = packageInfo.versionName ?: UNKNOWN, + osVersion = Build.VERSION.RELEASE ?: UNKNOWN, + sdkVersion = sdkVersion, + locale = locale(), + deviceModel = Build.MODEL ?: UNKNOWN, + deviceInstalledAtSeconds = packageInfo.firstInstallTime + .coerceAtLeast(0) / MILLIS_IN_SECOND, + ).takeIf { it.isValid() } + } + + private fun packageInfo() = try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.getPackageInfo( + context.packageName, + PackageManager.PackageInfoFlags.of(0L), + ) + } else { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo(context.packageName, 0) + } + } catch (_: Exception) { + null + } + + /** + * `Locale.getLanguage()` still returns the pre-1989 ISO-639 codes (`iw`, `in`, `ji` instead of + * `he`, `id`, `yi`), which would silently miss those users in locale targeting. + * `toLanguageTag()` gives the modern BCP-47 subtags; the separator is normalised to `_` to + * match the shape the gateway contract documents (`en_US`). + */ + private fun locale(): String { + val tag = try { + Locale.getDefault().toLanguageTag() + } catch (_: Exception) { + "" + } + return if (tag.isEmpty() || tag == UNDETERMINED_LANGUAGE_TAG) UNKNOWN else tag.replace('-', '_') + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewaySession.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewaySession.kt new file mode 100644 index 00000000..6ec98117 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewaySession.kt @@ -0,0 +1,168 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.internal.storage.Cache +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import java.nio.ByteBuffer +import java.security.MessageDigest + +private const val REMOTE_CONFIG_SESSION_PREFIX = "qonversion_remote_config_v2_session_" +private const val REMOTE_CONFIG_SESSION_VERSION = 1 +private const val REMOTE_CONFIG_SESSION_MAX_BYTES = 4 * 1024 +private const val REMOTE_CONFIG_SESSION_TOKEN_MAX_BYTES = 2 * 1024 + +/** + * A Remote Config v2 gateway session obtained from the bootstrap route. + * + * The session token authorises snapshot reads for exactly one identity scope. It is + * intentionally *not* a device-wide credential: it is stored and looked up per + * [RemoteConfigSnapshotScope], so an identity switch can never reuse the previous + * identity's token. + */ +internal data class RemoteConfigGatewaySession( + val token: String, + val projectId: Long, + val environment: String, + val expiresAtMillis: Long, +) { + fun isUsableAt(nowMillis: Long): Boolean = + token.isNotEmpty() && projectId > 0 && environment.isNotEmpty() && nowMillis < expiresAtMillis +} + +/** + * Addresses one stored session. + * + * The snapshot [scope] alone is not enough: the gateway mints a session for a specific anonymous + * [userUid], and the canonical user id of the scope is a separate notion that can in principle + * stay put while the anonymous uid is re-minted. Keying on both means a session can only ever be + * replayed for the exact identity it was issued to. + */ +internal data class RemoteConfigSessionKey( + val scope: RemoteConfigSnapshotScope, + val userUid: String, +) + +internal interface RemoteConfigSessionStore { + fun load(key: RemoteConfigSessionKey): RemoteConfigGatewaySession? + fun save(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession): Boolean + fun clear(key: RemoteConfigSessionKey): Boolean +} + +/** + * Durable, per-identity-scope session storage. + * + * Mirrors [PersistentRemoteConfigFetchPolicyStore]: the storage key is a salted digest of the + * scope, so neither the project key nor the canonical user id ever lands in a preference name, + * and a scope change simply addresses a different record. + */ +internal class PersistentRemoteConfigSessionStore( + private val cache: Cache, + moshi: Moshi, +) : RemoteConfigSessionStore { + private val adapter = moshi.adapter(PersistedRemoteConfigGatewaySession::class.java) + + @Synchronized + @Suppress("ReturnCount") + override fun load(key: RemoteConfigSessionKey): RemoteConfigGatewaySession? { + val storageKey = remoteConfigSessionStorageKey(key) + val raw = try { + cache.getString(storageKey, null) + } catch (_: Exception) { + null + } ?: return null + val persisted = try { + raw.takeIf { it.toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_SESSION_MAX_BYTES } + ?.let(adapter::fromJson) + } catch (_: Exception) { + null + } + if (persisted == null || !persisted.isValid()) { + removeInvalid(storageKey) + return null + } + return RemoteConfigGatewaySession( + token = persisted.token, + projectId = persisted.projectId, + environment = persisted.environment, + expiresAtMillis = persisted.expiresAtMillis, + ) + } + + @Synchronized + @Suppress("ReturnCount") + override fun save(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession): Boolean { + val persisted = PersistedRemoteConfigGatewaySession( + version = REMOTE_CONFIG_SESSION_VERSION, + token = session.token, + projectId = session.projectId, + environment = session.environment, + expiresAtMillis = session.expiresAtMillis, + ) + if (!persisted.isValid()) return false + val raw = try { + adapter.toJson(persisted) + } catch (_: Exception) { + return false + } + if (raw.toByteArray(Charsets.UTF_8).size > REMOTE_CONFIG_SESSION_MAX_BYTES) return false + return try { + cache.updateStringsDurably( + values = mapOf(remoteConfigSessionStorageKey(key) to raw), + removedKeys = emptySet(), + ) + } catch (_: Exception) { + false + } + } + + @Synchronized + override fun clear(key: RemoteConfigSessionKey): Boolean = try { + cache.updateStringsDurably(emptyMap(), setOf(remoteConfigSessionStorageKey(key))) + } catch (_: Exception) { + false + } + + private fun PersistedRemoteConfigGatewaySession.isValid(): Boolean = + version == REMOTE_CONFIG_SESSION_VERSION && + token.isNotEmpty() && + token.toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_SESSION_TOKEN_MAX_BYTES && + projectId > 0 && + environment.isNotEmpty() && + expiresAtMillis > 0 + + private fun removeInvalid(key: String) { + try { + cache.updateStringsDurably(emptyMap(), setOf(key)) + } catch (_: Exception) { + // A malformed session stays untrusted even when best-effort cleanup fails. + } + } +} + +@JsonClass(generateAdapter = true) +internal data class PersistedRemoteConfigGatewaySession( + val version: Int, + @Json(name = "session_token") + val token: String, + @Json(name = "project_id") + val projectId: Long, + val environment: String, + @Json(name = "expires_at_millis") + val expiresAtMillis: Long, +) + +private fun remoteConfigSessionStorageKey(key: RemoteConfigSessionKey): String { + val digest = MessageDigest.getInstance("SHA-256") + digest.updateLengthPrefixed("remote-config-gateway-session-v1".encodeToByteArray()) + digest.updateLengthPrefixed(key.scope.projectKey.encodeToByteArray()) + digest.updateLengthPrefixed(key.scope.environment.encodeToByteArray()) + digest.updateLengthPrefixed(key.scope.canonicalUserId.encodeToByteArray()) + digest.updateLengthPrefixed(key.userUid.encodeToByteArray()) + return REMOTE_CONFIG_SESSION_PREFIX + digest.digest().joinToString("") { byte -> "%02x".format(byte) } +} + +private fun MessageDigest.updateLengthPrefixed(value: ByteArray) { + update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(value.size).array()) + update(value) +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransport.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransport.kt new file mode 100644 index 00000000..3f234e4f --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransport.kt @@ -0,0 +1,556 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.internal.logger.Logger +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import okhttp3.Call +import okhttp3.Callback +import okhttp3.HttpUrl +import okhttp3.MediaType +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.Response +import okhttp3.ResponseBody +import java.io.IOException +import java.util.GregorianCalendar +import java.util.TimeZone +import java.util.concurrent.atomic.AtomicBoolean + +internal const val REMOTE_CONFIG_SESSION_PATH = "v3/remote-config-v2/session" +internal const val REMOTE_CONFIG_SNAPSHOT_PATH = "v3/remote-config-v2/snapshot" +internal const val REMOTE_CONFIG_SESSION_HEADER = "X-Qonversion-RC-Session" +internal const val REMOTE_CONFIG_SNAPSHOT_BODY_MAX_BYTES = 8L * 1024 * 1024 + +private const val REMOTE_CONFIG_USER_UID_MAX_BYTES = 255 +private const val REMOTE_CONFIG_SESSION_TOKEN_HEADER_MAX_BYTES = 512 +private const val REMOTE_CONFIG_CLIENT_CONTEXT_SCALAR_MAX_BYTES = 256 +private const val REMOTE_CONFIG_SESSION_EXPIRY_SKEW_MILLIS = 30_000L +private const val MILLIS_PER_SECOND = 1_000L +private const val HTTP_OK = 200 +private const val HTTP_NOT_MODIFIED = 304 +private const val HTTP_UNAUTHORIZED = 401 +private const val ASCII_PRINTABLE_MIN = 0x20 +private const val ASCII_PRINTABLE_MAX = 0x7e + +/** + * Device-scoped facts the gateway needs to evaluate targeting. + * + * [deviceInstalledAtSeconds] is a DEVICE fact, not an identity fact: the server evaluates + * account age as `min(device_installed_at, client.created_at)`, so a fresh anonymous client row + * minted after a logout looks "new" and only the preserved device install date keeps a + * long-standing user out of "new users" targeting. Producers of this value must therefore read it + * from a device-scoped source that is unaffected by identify/logout — see + * [DeviceRemoteConfigClientContextProvider], whose constructor deliberately takes no identity. + */ +internal data class RemoteConfigClientContext( + val platform: String, + val appVersion: String, + val osVersion: String, + val sdkVersion: String, + val locale: String, + val deviceModel: String, + val deviceInstalledAtSeconds: Long, +) { + internal fun isValid(): Boolean = deviceInstalledAtSeconds >= 0 && + scalars().all { it.isNotEmpty() && it.isWithinScalarBudget() } + + private fun scalars() = listOf(platform, appVersion, osVersion, sdkVersion, locale, deviceModel) + + private fun String.isWithinScalarBudget(): Boolean = + toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_CLIENT_CONTEXT_SCALAR_MAX_BYTES +} + +/** + * Supplies the device-scoped client context for every snapshot request. + * + * Implementations MUST NOT derive [RemoteConfigClientContext.deviceInstalledAtSeconds] from + * anything that is reset by an identity change. + */ +internal fun interface RemoteConfigClientContextProvider { + fun clientContext(): RemoteConfigClientContext? +} + +/** + * Everything the transport needs to address one identity: the snapshot [scope] the session is + * stored under, the SDK project token used as the bearer credential, and the anonymous SDK uid + * the bootstrap route mints a session for. + * + * [projectToken] is validated as an HTTP header value, not merely as a non-empty string: it is + * interpolated into `Authorization`, and OkHttp rejects a non-printable byte by throwing an + * `IllegalArgumentException` whose message quotes the offending value — i.e. the credential. + */ +internal data class RemoteConfigTransportIdentity( + val scope: RemoteConfigSnapshotScope, + val projectToken: String, + val userUid: String, +) { + internal val sessionKey: RemoteConfigSessionKey get() = RemoteConfigSessionKey(scope, userUid) + + internal fun isValid(): Boolean = projectToken.isNotEmpty() && + projectToken.isHttpHeaderSafe() && + userUid.isNotEmpty() && + userUid.toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_USER_UID_MAX_BYTES && + !userUid.contains(UNICODE_REPLACEMENT_CHARACTER) + + private companion object { + const val UNICODE_REPLACEMENT_CHARACTER = '�' + } +} + +internal fun interface RemoteConfigTransportIdentityProvider { + fun currentIdentity(): RemoteConfigTransportIdentity? +} + +/** + * Binds [RemoteConfigFetchCoordinator]'s transport seam to the internal Remote Config v2 gateway. + * + * Responsibilities, in the order the coordinator observes them: + * 1. Bootstrap on a missing (or expired) session — `POST {base}/v3/remote-config-v2/session`. + * 2. Read the snapshot — `POST {base}/v3/remote-config-v2/snapshot` with the session header and, + * when the coordinator holds a conditional validator, the exact `If-None-Match` value. + * 3. Re-bootstrap exactly once on a snapshot `401`, then retry the snapshot once. A second `401` + * is a typed failure, never another bootstrap — the flow cannot loop. + * 4. Hand the response body to the coordinator as the EXACT bytes received, paired with the exact + * `ETag` header. Nothing is decoded, re-encoded or charset-converted on the way in. + * + * The completion is invoked exactly once on every path, including one that throws on an OkHttp + * dispatcher thread: the coordinator parks a waiter on it, and a lost completion would strand that + * waiter until its (optional) timeout. + * + * The [callFactory] must NOT carry the legacy `NetworkInterceptor`: this transport owns its + * request headers (including `Authorization`) and a second interceptor-provided value would be + * appended rather than replaced. + * + * Neither the project token nor the session token is ever logged. + */ +@Suppress("LongParameterList") +internal class RemoteConfigGatewayTransport( + private val callFactory: Call.Factory, + private val baseUrlProvider: () -> String, + private val identityProvider: RemoteConfigTransportIdentityProvider, + private val clientContextProvider: RemoteConfigClientContextProvider, + private val sessionStore: RemoteConfigSessionStore, + private val clock: RemoteConfigFetchClock, + moshi: Moshi, + private val logger: Logger, + private val maxSnapshotBodyBytes: Long = REMOTE_CONFIG_SNAPSHOT_BODY_MAX_BYTES, +) : RemoteConfigFetchTransport { + private val bootstrapRequestAdapter = moshi.adapter(RemoteConfigSessionRequest::class.java) + private val bootstrapResponseAdapter = moshi.adapter(RemoteConfigSessionResponse::class.java) + private val snapshotRequestAdapter = moshi.adapter(RemoteConfigSnapshotRequest::class.java) + + private val lock = Any() + private var cachedKey: RemoteConfigSessionKey? = null + private var cachedSession: RemoteConfigGatewaySession? = null + + override fun fetch( + request: RemoteConfigFetchRequest, + completion: (RemoteConfigFetchResponse) -> Unit, + ) { + val deliver = SingleDelivery(completion) + val identity = identityProvider.currentIdentity()?.takeIf { it.isValid() } + val context = clientContextProvider.clientContext()?.takeIf { it.isValid() } + if (identity == null || context == null) { + logger.debug("Remote Config v2 transport is not addressable yet") + deliver(RemoteConfigFetchResponse.Failure()) + return + } + val session = loadUsableSession(identity.sessionKey) + if (session == null) { + // Bootstrap-on-missing-session. The snapshot that follows a fresh mint may not + // re-bootstrap on 401 — that is what keeps the flow finite. + mint(identity, deliver) { minted -> + requestSnapshot(identity, context, minted, request, deliver, allowReBootstrap = false) + } + } else { + requestSnapshot(identity, context, session, request, deliver, allowReBootstrap = true) + } + } + + @Suppress("LongParameterList") + private fun requestSnapshot( + identity: RemoteConfigTransportIdentity, + context: RemoteConfigClientContext, + session: RemoteConfigGatewaySession, + request: RemoteConfigFetchRequest, + deliver: SingleDelivery, + allowReBootstrap: Boolean, + ) { + val body = try { + snapshotRequestAdapter.toJson(RemoteConfigSnapshotRequest(context.toWire())) + } catch (_: Throwable) { + null + } + val httpRequest = body?.let { + buildRequest(REMOTE_CONFIG_SNAPSHOT_PATH, identity, it) { builder -> + builder.header(REMOTE_CONFIG_SESSION_HEADER, session.token) + request.ifNoneMatch + ?.takeIf { validator -> validator.isNotEmpty() && validator.isHttpHeaderSafe() } + ?.let { validator -> builder.header("If-None-Match", validator) } + } + } + if (httpRequest == null) { + deliver(RemoteConfigFetchResponse.Failure()) + return + } + enqueue(httpRequest, deliver) { outcome -> + onSnapshotOutcome(identity, context, request, deliver, allowReBootstrap, outcome) + } + } + + @Suppress("LongParameterList") + private fun onSnapshotOutcome( + identity: RemoteConfigTransportIdentity, + context: RemoteConfigClientContext, + request: RemoteConfigFetchRequest, + deliver: SingleDelivery, + allowReBootstrap: Boolean, + outcome: HttpOutcome?, + ) { + when { + outcome == null -> deliver(RemoteConfigFetchResponse.Failure()) + outcome.code == HTTP_OK -> deliver(outcome.asSuccessOrFailure()) + outcome.code == HTTP_NOT_MODIFIED -> + deliver(RemoteConfigFetchResponse.NotModified(outcome.etag)) + outcome.code == HTTP_UNAUTHORIZED -> { + forgetSession(identity.sessionKey) + if (!allowReBootstrap) { + logger.debug("Remote Config v2 snapshot stayed unauthorized after re-bootstrap") + deliver(RemoteConfigFetchResponse.Failure(statusCode = outcome.code)) + return + } + mint(identity, deliver) { session -> + requestSnapshot(identity, context, session, request, deliver, allowReBootstrap = false) + } + } + else -> deliver(outcome.asFailure()) + } + } + + private fun mint( + identity: RemoteConfigTransportIdentity, + deliver: SingleDelivery, + onMinted: (RemoteConfigGatewaySession) -> Unit, + ) { + val body = try { + bootstrapRequestAdapter.toJson(RemoteConfigSessionRequest(identity.userUid)) + } catch (_: Throwable) { + null + } + val httpRequest = body?.let { buildRequest(REMOTE_CONFIG_SESSION_PATH, identity, it) } + if (httpRequest == null) { + deliver(RemoteConfigFetchResponse.Failure()) + return + } + enqueue(httpRequest, deliver) { outcome -> + val session = outcome + ?.takeIf { it.code == HTTP_OK } + ?.body + ?.let { readSession(it) } + if (session == null) { + logger.debug("Remote Config v2 session bootstrap failed with code ${outcome?.code}") + // A 200 that does not carry a usable session is a contract violation, not a + // status the fetch policy should reason about. + deliver(if (outcome?.code == HTTP_OK) RemoteConfigFetchResponse.Failure() else outcome.asFailure()) + return@enqueue + } + rememberSession(identity.sessionKey, session) + onMinted(session) + } + } + + /** + * Builds a request, returning `null` instead of throwing. `Request.Builder.header` rejects + * non-printable values by throwing, and this is reached from OkHttp callback threads. + */ + private fun buildRequest( + path: String, + identity: RemoteConfigTransportIdentity, + body: String, + configure: (Request.Builder) -> Unit = {}, + ): Request? = try { + val url = HttpUrl.parse(baseUrlProvider())?.newBuilder()?.addPathSegments(path)?.build() + url?.let { + Request.Builder() + .url(it) + .header("Authorization", "Bearer ${identity.projectToken}") + .header("Content-Type", JSON_CONTENT_TYPE) + .header("Accept", "application/json") + // The gateway answers `private, no-store`; declaring it on the request as well + // keeps a shared OkHttp cache from ever synthesising a body the parser never saw. + .header("Cache-Control", "no-store") + .post(RequestBody.create(JSON_MEDIA_TYPE, body.toByteArray(Charsets.UTF_8))) + .also(configure) + .build() + } + } catch (_: Throwable) { + null + } + + /** + * Every exit of this method must end in exactly one [deliver] call: the coordinator parks a + * waiter on the callback, so a swallowed throw on an OkHttp dispatcher thread would strand it + * until its timeout instead of failing fast. + */ + private fun enqueue(request: Request, deliver: SingleDelivery, onOutcome: (HttpOutcome?) -> Unit) { + fun handle(outcome: HttpOutcome?) = try { + onOutcome(outcome) + } catch (_: Throwable) { + deliver(RemoteConfigFetchResponse.Failure()) + } + + val call = try { + callFactory.newCall(request) + } catch (_: Throwable) { + handle(null) + return + } + val callback = object : Callback { + override fun onFailure(call: Call, e: IOException) = handle(null) + + override fun onResponse(call: Call, response: Response) { + val outcome = try { + response.use { it.toOutcome() } + } catch (_: Throwable) { + null + } + handle(outcome) + } + } + try { + call.enqueue(callback) + } catch (_: Throwable) { + handle(null) + } + } + + private fun Response.toOutcome(): HttpOutcome = HttpOutcome( + code = code(), + body = if (code() == HTTP_NOT_MODIFIED) null else body()?.readBounded(maxSnapshotBodyBytes), + etag = header("ETag"), + retryAfterMillis = header("Retry-After").parseRetryAfterMillis(), + ) + + /** + * Reads at most [max] bytes as the raw octet stream: no charset decode, no re-encode, no JSON + * round trip. A body over budget yields `null` rather than an unbounded allocation. + */ + private fun ResponseBody.readBounded(max: Long): ByteArray? { + val source = source() + source.request(max + 1) + return if (source.buffer().size > max) null else source.readByteArray() + } + + @Suppress("ReturnCount") + private fun loadUsableSession(key: RemoteConfigSessionKey): RemoteConfigGatewaySession? { + val now = nowMillis() + val cached = synchronized(lock) { cachedSession.takeIf { cachedKey == key } } + if (cached != null && cached.isUsable(now)) return cached + // A dead in-memory slot must not shadow the durable record, and a dead durable record must + // be dropped rather than re-read on every fetch. + val persisted = try { + sessionStore.load(key) + } catch (_: Throwable) { + null + } + if (persisted == null || !persisted.isUsable(now)) { + forgetSession(key) + return null + } + synchronized(lock) { + cachedKey = key + cachedSession = persisted + } + return persisted + } + + private fun rememberSession(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession) { + synchronized(lock) { + cachedKey = key + cachedSession = session + } + // A session whose expiry could not be trusted is used for this fetch only: persisting it + // would hand a later cold start a credential we cannot reason about. + if (session.expiresAtMillis <= nowMillis()) return + try { + sessionStore.save(key, session) + } catch (_: Throwable) { + // The in-memory session still serves this process; the next cold start re-bootstraps. + } + } + + private fun forgetSession(key: RemoteConfigSessionKey) { + synchronized(lock) { + if (cachedKey == key) { + cachedSession = null + cachedKey = null + } + } + try { + sessionStore.clear(key) + } catch (_: Throwable) { + // A stale record is re-validated (and dropped again) on the next load. + } + } + + @Suppress("ReturnCount") + private fun readSession(body: ByteArray): RemoteConfigGatewaySession? { + val parsed = try { + bootstrapResponseAdapter.fromJson(body.toString(Charsets.UTF_8)) + } catch (_: Throwable) { + null + } ?: return null + val token = parsed.sessionToken ?: return null + if (!token.isUsableSessionToken()) return null + val projectId = parsed.projectId?.takeIf { it > 0 } ?: return null + val environment = parsed.environment?.takeIf { it.isNotEmpty() } ?: return null + return RemoteConfigGatewaySession( + token = token, + projectId = projectId, + // The session environment ("prod") lives in a different namespace than the snapshot + // scope environment uid, so it is recorded rather than compared. + environment = environment, + expiresAtMillis = parsed.expiresAt.parseRfc3339Millis() ?: 0, + ) + } + + private fun RemoteConfigGatewaySession.isUsable(nowMillis: Long): Boolean = + isUsableAt(nowMillis + REMOTE_CONFIG_SESSION_EXPIRY_SKEW_MILLIS) + + private fun nowMillis(): Long = try { + clock.nowMillis().coerceAtLeast(0) + } catch (_: Throwable) { + 0 + } + + private fun RemoteConfigClientContext.toWire() = RemoteConfigClientContextWire( + platform = platform, + appVersion = appVersion, + osVersion = osVersion, + sdkVersion = sdkVersion, + locale = locale, + deviceModel = deviceModel, + deviceInstalledAt = deviceInstalledAtSeconds, + ) + + private class SingleDelivery( + private val completion: (RemoteConfigFetchResponse) -> Unit, + ) : (RemoteConfigFetchResponse) -> Unit { + private val delivered = AtomicBoolean(false) + + override fun invoke(response: RemoteConfigFetchResponse) { + if (delivered.compareAndSet(false, true)) completion(response) + } + } + + private class HttpOutcome( + val code: Int, + val body: ByteArray?, + val etag: String?, + val retryAfterMillis: Long?, + ) { + fun asSuccessOrFailure(): RemoteConfigFetchResponse { + val bytes = body + val validator = etag + return if (bytes == null || bytes.isEmpty() || validator.isNullOrEmpty()) { + // An empty body, an over-budget body or a 200 without a strong validator cannot be + // admitted, and none of them is retryable. + RemoteConfigFetchResponse.Failure() + } else { + RemoteConfigFetchResponse.Success(bytes, validator) + } + } + } + + private companion object { + const val JSON_CONTENT_TYPE = "application/json; charset=utf-8" + val JSON_MEDIA_TYPE: MediaType? = MediaType.parse(JSON_CONTENT_TYPE) + + fun HttpOutcome?.asFailure() = RemoteConfigFetchResponse.Failure( + statusCode = this?.code, + retryAfterMillis = this?.retryAfterMillis, + ) + } +} + +internal fun String.isHttpHeaderSafe(): Boolean = + all { character -> character.code in ASCII_PRINTABLE_MIN..ASCII_PRINTABLE_MAX } + +private fun String.isUsableSessionToken(): Boolean = isNotEmpty() && + trim() == this && + isHttpHeaderSafe() && + toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_SESSION_TOKEN_HEADER_MAX_BYTES + +private fun String?.parseRetryAfterMillis(): Long? = + this?.trim()?.toLongOrNull()?.takeIf { it >= 0 }?.let { seconds -> + if (seconds > Long.MAX_VALUE / MILLIS_PER_SECOND) Long.MAX_VALUE else seconds * MILLIS_PER_SECOND + } + +/** + * RFC 3339 timestamps, as a Go gateway emits them. + * + * `time.Time` marshals as RFC3339Nano with trailing zeros stripped, so the fraction is 0-9 digits + * wide rather than the 3 a `SimpleDateFormat` pattern can express, and RFC 3339 §5.6 allows a + * lowercase `t`/`z`. Both are parsed here; anything else yields `null`, which the caller treats as + * "expiry unknown" (usable for this fetch, never persisted). + */ +@Suppress("MagicNumber", "ReturnCount") +private fun String?.parseRfc3339Millis(): Long? { + val match = RFC3339_PATTERN.matchEntire(this?.trim().orEmpty()) ?: return null + val (year, month, day, hour, minute, second, fraction, sign, offsetHour, offsetMinute) = + match.destructured + val calendar = GregorianCalendar(TimeZone.getTimeZone("UTC")).apply { + isLenient = false + clear() + set(year.toInt(), month.toInt() - 1, day.toInt(), hour.toInt(), minute.toInt(), second.toInt()) + } + val epochMillis = try { + calendar.timeInMillis + } catch (_: IllegalArgumentException) { + return null + } + val fractionMillis = fraction.takeIf { it.isNotEmpty() } + ?.padEnd(3, '0')?.substring(0, 3)?.toLong() ?: 0 + val offsetMillis = if (sign.isEmpty()) { + 0 + } else { + val magnitude = (offsetHour.toLong() * 60 + offsetMinute.toLong()) * 60 * MILLIS_PER_SECOND + if (sign == "-") -magnitude else magnitude + } + return epochMillis + fractionMillis - offsetMillis +} + +private val RFC3339_PATTERN = Regex( + "(\\d{4})-(\\d{2})-(\\d{2})[Tt](\\d{2}):(\\d{2}):(\\d{2})(?:\\.(\\d{1,9}))?" + + "(?:[Zz]|([+\\-])(\\d{2}):(\\d{2}))", +) + +@JsonClass(generateAdapter = true) +internal data class RemoteConfigSessionRequest( + @Json(name = "user_uid") val userUid: String, +) + +@JsonClass(generateAdapter = true) +internal data class RemoteConfigSessionResponse( + @Json(name = "session_token") val sessionToken: String?, + @Json(name = "project_id") val projectId: Long?, + @Json(name = "environment") val environment: String?, + @Json(name = "expires_at") val expiresAt: String?, +) + +@JsonClass(generateAdapter = true) +internal data class RemoteConfigSnapshotRequest( + @Json(name = "client_context") val clientContext: RemoteConfigClientContextWire, +) + +@JsonClass(generateAdapter = true) +internal data class RemoteConfigClientContextWire( + @Json(name = "platform") val platform: String, + @Json(name = "app_version") val appVersion: String, + @Json(name = "os_version") val osVersion: String, + @Json(name = "sdk_version") val sdkVersion: String, + @Json(name = "locale") val locale: String, + @Json(name = "device_model") val deviceModel: String, + @Json(name = "device_installed_at") val deviceInstalledAt: Long, +) diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/DeviceRemoteConfigClientContextProviderTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/DeviceRemoteConfigClientContextProviderTest.kt new file mode 100644 index 00000000..e716824c --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/DeviceRemoteConfigClientContextProviderTest.kt @@ -0,0 +1,61 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import android.os.Build +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +/** + * Pins the DEVICE scope of `device_installed_at`. + * + * Robolectric gives a real [android.content.pm.PackageManager] whose `firstInstallTime` can be + * controlled, which is the only way to prove the provider reads the device install date rather + * than anything identity-shaped. + */ +@RunWith(RobolectricTestRunner::class) +internal class DeviceRemoteConfigClientContextProviderTest { + + @Test + fun `device_installed_at is the package first install time in epoch seconds`() { + setFirstInstallTime(1_577_836_800_123) + + val context = requireNotNull(provider().clientContext()) + + assertEquals(1_577_836_800, context.deviceInstalledAtSeconds) + assertEquals("android", context.platform) + assertEquals("9.7.0", context.sdkVersion) + assertEquals(Build.MODEL, context.deviceModel) + assertNotNull(context.locale) + } + + @Test + fun `device_installed_at does not move when the identity does`() { + // The provider is constructed without any identity input, so a logout / identify cycle + // cannot reach it. Two independent instances must agree, and must keep agreeing after the + // SDK would have minted a new anonymous uid. + setFirstInstallTime(1_577_836_800_000) + + val before = requireNotNull(provider().clientContext()).deviceInstalledAtSeconds + val after = requireNotNull(provider().clientContext()).deviceInstalledAtSeconds + + assertEquals(1_577_836_800, before) + assertEquals(before, after) + } + + private fun provider() = DeviceRemoteConfigClientContextProvider( + context = RuntimeEnvironment.getApplication(), + sdkVersion = "9.7.0", + ) + + private fun setFirstInstallTime(millis: Long) { + val application = RuntimeEnvironment.getApplication() + val packageInfo = shadowOf(application.packageManager) + .getInternalMutablePackageInfo(application.packageName) + packageInfo.firstInstallTime = millis + packageInfo.versionName = "1.2.3" + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigSessionStoreTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigSessionStoreTest.kt new file mode 100644 index 00000000..d462ff04 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigSessionStoreTest.kt @@ -0,0 +1,125 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.internal.storage.Cache +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class PersistentRemoteConfigSessionStoreTest { + private val scope = RemoteConfigSnapshotScope("project-secret", "env-production", "customer-secret") + private val key = RemoteConfigSessionKey(scope, "QON_anon_a") + private val otherIdentity = RemoteConfigSessionKey( + RemoteConfigSnapshotScope("project-secret", "env-production", "other-secret"), + "QON_anon_b", + ) + private val sameScopeNewUid = RemoteConfigSessionKey(scope, "QON_anon_c") + private val session = RemoteConfigGatewaySession( + token = "qrcs1.session-secret", + projectId = 42, + environment = "prod", + expiresAtMillis = 1_800_000_000_000, + ) + + @Test + fun `session is durably scoped and survives a new store instance`() { + val cache = MapCache() + assertTrue(store(cache).save(key, session)) + + val persistedKey = cache.strings.keys.single() + assertFalse(persistedKey.contains("project-secret")) + assertFalse(persistedKey.contains("customer-secret")) + assertFalse(persistedKey.contains("QON_anon_a")) + assertEquals(session, store(cache).load(key)) + } + + @Test + fun `an identity change addresses a different record and never reads the previous token`() { + val cache = MapCache() + assertTrue(store(cache).save(key, session)) + + assertNull(store(cache).load(otherIdentity)) + assertTrue(store(cache).save(otherIdentity, session.copy(token = "qrcs1.other"))) + assertEquals(2, cache.strings.size) + assertEquals("qrcs1.session-secret", store(cache).load(key)?.token) + assertEquals("qrcs1.other", store(cache).load(otherIdentity)?.token) + } + + @Test + fun `a re-minted anonymous uid under the same scope addresses a different record`() { + // The scope's canonical user id can stay put while the SDK mints a new anonymous uid; the + // session was issued for the uid, so it must not be replayed for the new one. + val cache = MapCache() + assertTrue(store(cache).save(key, session)) + + assertNull(store(cache).load(sameScopeNewUid)) + assertEquals(session.token, store(cache).load(key)?.token) + } + + @Test + fun `clearing drops only the addressed identity`() { + val cache = MapCache() + val sessionStore = store(cache) + assertTrue(sessionStore.save(key, session)) + assertTrue(sessionStore.save(otherIdentity, session.copy(token = "qrcs1.other"))) + + assertTrue(sessionStore.clear(key)) + + assertNull(sessionStore.load(key)) + assertEquals("qrcs1.other", sessionStore.load(otherIdentity)?.token) + } + + @Test + fun `malformed persisted session is removed fail closed`() { + val cache = MapCache() + val sessionStore = store(cache) + assertTrue(sessionStore.save(key, session)) + val persistedKey = cache.strings.keys.single() + cache.strings[persistedKey] = + "{\"version\":1,\"session_token\":\"\",\"project_id\":42," + + "\"environment\":\"prod\",\"expires_at_millis\":1}" + + assertNull(sessionStore.load(key)) + assertFalse(cache.strings.containsKey(persistedKey)) + } + + @Test + fun `a session that cannot be described is refused rather than half written`() { + val cache = MapCache() + + assertFalse(store(cache).save(key, session.copy(token = ""))) + assertFalse(store(cache).save(key, session.copy(projectId = 0))) + assertFalse(store(cache).save(key, session.copy(expiresAtMillis = 0))) + assertTrue(cache.strings.isEmpty()) + } + + private fun store(cache: Cache) = PersistentRemoteConfigSessionStore(cache, Moshi.Builder().build()) + + private class MapCache : Cache { + val strings = mutableMapOf() + + override fun putInt(key: String, value: Int) = Unit + override fun getInt(key: String, defValue: Int): Int = defValue + override fun getBool(key: String, defValue: Boolean): Boolean = defValue + override fun putBool(key: String, value: Boolean) = Unit + override fun putFloat(key: String, value: Float) = Unit + override fun getFloat(key: String, defValue: Float): Float = defValue + override fun putLong(key: String, value: Long) = Unit + override fun getLong(key: String, defValue: Long): Long = defValue + override fun putString(key: String, value: String?) { strings[key] = value } + override fun getString(key: String, defValue: String?): String? = strings[key] ?: defValue + override fun remove(key: String) { strings.remove(key) } + + override fun updateStringsDurably(values: Map, removedKeys: Set): Boolean { + removedKeys.forEach(strings::remove) + strings.putAll(values) + return true + } + + override fun putObject(key: String, value: T, adapter: JsonAdapter) = Unit + override fun getObject(key: String, adapter: JsonAdapter): T? = null + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportCoordinatorTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportCoordinatorTest.kt new file mode 100644 index 00000000..7156bbca --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportCoordinatorTest.kt @@ -0,0 +1,266 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.internal.logger.Logger +import com.qonversion.android.sdk.internal.storage.RemoteConfigSnapshotLoadResult +import com.qonversion.android.sdk.internal.storage.RemoteConfigSnapshotLoadStatus +import com.qonversion.android.sdk.internal.storage.RemoteConfigSnapshotStore +import com.squareup.moshi.Moshi +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import okio.Buffer +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.security.MessageDigest +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * End-to-end proof that [RemoteConfigGatewayTransport] plugs into the existing fetch-policy engine: + * real [RemoteConfigFetchCoordinator], real [RemoteConfigSnapshotCore] with the strict wire parser, + * real HTTP over [MockWebServer]. Nothing between the socket and durable admission is stubbed. + */ +internal class RemoteConfigGatewayTransportCoordinatorTest { + private lateinit var server: MockWebServer + private lateinit var client: OkHttpClient + private val snapshotStore = InMemorySnapshotStore() + private val policyStore = InMemoryFetchPolicyStore() + private val scheduler = ManualScheduler() + + @Before + fun setUp() { + server = MockWebServer() + server.start() + client = OkHttpClient() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `server bytes reach durable admission unchanged`() { + val core = core() + val coordinator = coordinator(core) + coordinator.transitionTo(BINDING) + val body = WIRE_BODY.toByteArray(Charsets.UTF_8) + server.enqueue(sessionResponse()) + server.enqueue(snapshotResponse(body, strongETag(body))) + + val result = fetch(coordinator) + + val fetched = result as RemoteConfigFetchResult.Fetched + assertTrue( + fetched.transition.status.name, + fetched.transition.status == RemoteConfigSnapshotTransitionStatus.Accepted || + fetched.transition.status == RemoteConfigSnapshotTransitionStatus.Activated, + ) + val admitted = requireNotNull(snapshotStore.states[SCOPE]?.candidate) + assertArrayEquals(body, admitted.canonicalBodyBytes) + assertEquals(strongETag(body), admitted.strongETag) + } + + @Test + fun `304 is recovered against the current head instead of re-admitting`() { + val core = core() + val coordinator = coordinator(core) + coordinator.transitionTo(BINDING) + val body = WIRE_BODY.toByteArray(Charsets.UTF_8) + server.enqueue(sessionResponse()) + server.enqueue(snapshotResponse(body, strongETag(body))) + assertTrue(fetch(coordinator) is RemoteConfigFetchResult.Fetched) + server.takeRequest() + server.takeRequest() + + server.enqueue(MockResponse().setResponseCode(304).setHeader("ETag", strongETag(body))) + val result = fetch(coordinator) + + assertEquals(RemoteConfigFetchResult.NotModified, result) + // The session was reused, so the only new request is the conditional snapshot read. + val conditional = server.takeRequest() + assertEquals("/v3/remote-config-v2/snapshot", conditional.path) + assertEquals(strongETag(body), conditional.getHeader("If-None-Match")) + } + + @Test + fun `a stalled gateway times out through the fetch policy`() { + val core = core() + val coordinator = coordinator(core) + coordinator.transitionTo(BINDING) + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)) + + val latch = CountDownLatch(1) + var result: RemoteConfigFetchResult? = null + coordinator.fetch { fetchResult -> + result = fetchResult + latch.countDown() + } + scheduler.runNext() + + assertTrue(latch.await(AWAIT_SECONDS, TimeUnit.SECONDS)) + assertTrue(result is RemoteConfigFetchResult.TimedOut) + // The HTTP call is deliberately not cancelled on timeout: the coordinator fences the late + // response with a fresh admission token instead, so the request still reaches the server. + assertNotNull(server.takeRequest(AWAIT_SECONDS, TimeUnit.SECONDS)) + } + + private fun fetch(coordinator: RemoteConfigFetchCoordinator): RemoteConfigFetchResult { + val latch = CountDownLatch(1) + var result: RemoteConfigFetchResult? = null + coordinator.fetch { fetchResult -> + result = fetchResult + latch.countDown() + } + assertTrue("coordinator did not answer in time", latch.await(AWAIT_SECONDS, TimeUnit.SECONDS)) + return requireNotNull(result) + } + + private fun core() = RemoteConfigSnapshotCore(snapshotStore, bundledRelease = null) + + private fun coordinator(core: RemoteConfigSnapshotCore) = RemoteConfigFetchCoordinator( + core = core, + transport = transport(), + policyStore = policyStore, + clock = { CLOCK_MILLIS }, + random = { 0.5 }, + scheduler = scheduler, + policy = RemoteConfigFetchPolicy(minimumFetchIntervalMillis = 0, timeoutMillis = 5_000), + ) + + private fun transport() = RemoteConfigGatewayTransport( + callFactory = client, + baseUrlProvider = { server.url("/").toString() }, + identityProvider = { + RemoteConfigTransportIdentity(SCOPE, "project-key-secret", "QON_anon_a") + }, + clientContextProvider = { + RemoteConfigClientContext( + platform = "android", + appVersion = "1.2.3", + osVersion = "14", + sdkVersion = "9.7.0", + locale = "en_US", + deviceModel = "Pixel 8", + deviceInstalledAtSeconds = 1_577_836_800, + ) + }, + sessionStore = InMemorySessionStore(), + clock = { CLOCK_MILLIS }, + moshi = Moshi.Builder().build(), + logger = SilentLogger(), + ) + + private fun sessionResponse() = MockResponse() + .setResponseCode(200) + .setBody( + "{\"session_token\":\"qrcs1.session-secret\",\"project_id\":42," + + "\"environment\":\"prod\",\"expires_at\":\"2030-01-01T00:00:00Z\"}", + ) + + private fun snapshotResponse(body: ByteArray, etag: String) = MockResponse() + .setResponseCode(200) + .setHeader("ETag", etag) + .setBody(Buffer().write(body)) + + private class ManualScheduler : RemoteConfigFetchScheduler { + private val tasks = mutableListOf() + + override fun schedule(delayMillis: Long, action: () -> Unit): RemoteConfigFetchScheduledTask { + val task = Task(action) + synchronized(tasks) { tasks += task } + return RemoteConfigFetchScheduledTask { task.cancelled = true } + } + + fun runNext() { + val task = synchronized(tasks) { tasks.removeAt(0) } + if (!task.cancelled) task.action() + } + + private class Task(val action: () -> Unit, @Volatile var cancelled: Boolean = false) + } + + private class InMemorySessionStore : RemoteConfigSessionStore { + private val sessions = mutableMapOf() + + @Synchronized + override fun load(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 + } + } + + private class InMemoryFetchPolicyStore : RemoteConfigFetchPolicyStore { + private val states = mutableMapOf() + + @Synchronized + override fun load(scope: RemoteConfigFetchPolicyScope) = states[scope] + + @Synchronized + override fun save(scope: RemoteConfigFetchPolicyScope, state: RemoteConfigFetchPolicyState): Boolean { + states[scope] = state + return true + } + } + + private class InMemorySnapshotStore : RemoteConfigSnapshotStore { + val states = mutableMapOf() + + @Synchronized + override fun load(scope: RemoteConfigSnapshotScope): RemoteConfigSnapshotLoadResult = + states[scope]?.let { RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Found, it) } + ?: RemoteConfigSnapshotLoadResult(RemoteConfigSnapshotLoadStatus.Missing) + + @Synchronized + override fun save(scope: RemoteConfigSnapshotScope, state: RemoteConfigSnapshotState): Boolean { + states[scope] = state + return true + } + } + + private class SilentLogger : Logger { + override fun error(message: String) = Unit + override fun warn(message: String) = Unit + override fun release(message: String) = Unit + override fun debug(message: String) = Unit + } + + private companion object { + const val AWAIT_SECONDS = 10L + const val CLOCK_MILLIS = 1_000_000L + val SCOPE = RemoteConfigSnapshotScope("project", "production", "canonical-user") + val BINDING = RemoteConfigFetchBinding( + scope = SCOPE, + expectation = RemoteConfigSnapshotEnvelopeExpectation( + projectId = 42, + environmentUid = "production", + contextFingerprint = "a".repeat(64), + ), + ) + val WIRE_BODY = "{\"schema_version\":1,\"project_id\":42,\"environment_uid\":\"production\"," + + "\"release_uid\":\"release-1\",\"release_number\":1," + + "\"manifest_content_hash\":\"${"1".padStart(64, '0')}\"," + + "\"complete_key_set\":true,\"context_fingerprint\":\"${"a".repeat(64)}\"," + + "\"values\":{\"a\":{\"raw\":1,\"variation_uid\":\"variation-release-1\"," + + "\"apply_policy\":\"on_next_activate\",\"metadata\":null}}}" + + fun strongETag(body: ByteArray): String = MessageDigest.getInstance("SHA-256") + .digest(body) + .joinToString(prefix = "\"", postfix = "\"", separator = "") { byte -> "%02x".format(byte) } + } +} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportTest.kt new file mode 100644 index 00000000..d9b94421 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportTest.kt @@ -0,0 +1,570 @@ +package com.qonversion.android.sdk.internal.remoteconfig + +import com.qonversion.android.sdk.internal.logger.Logger +import com.qonversion.android.sdk.internal.storage.Cache +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okhttp3.mockwebserver.SocketPolicy +import okio.Buffer +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * HTTP contract tests for [RemoteConfigGatewayTransport] against a real [MockWebServer]. + * + * The SDK previously had no HTTP fixture at all (see `RedemptionManagerTest`, which hand-mocks + * Retrofit calls). This adapter is byte-exact by contract, so a real socket is the only fixture + * that can actually prove the bytes and headers on the wire. + */ +internal class RemoteConfigGatewayTransportTest { + private lateinit var server: MockWebServer + private lateinit var client: OkHttpClient + private lateinit var cache: MapCache + private lateinit var logger: RecordingLogger + private val clock = MutableClock(1_000_000) + private var identity: RemoteConfigTransportIdentity? = identityFor(SCOPE_A, USER_A) + private var clientContext = CLIENT_CONTEXT + + @Before + fun setUp() { + server = MockWebServer() + server.start() + client = OkHttpClient() + cache = MapCache() + logger = RecordingLogger() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `bootstrap and snapshot requests match the gateway contract exactly`() { + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + + val response = fetch(RemoteConfigFetchRequest()) + + val bootstrap = server.takeRequest() + assertEquals("POST", bootstrap.method) + assertEquals("/v3/remote-config-v2/session", bootstrap.path) + assertEquals("Bearer $PROJECT_TOKEN", bootstrap.getHeader("Authorization")) + assertEquals("application/json; charset=utf-8", bootstrap.getHeader("Content-Type")) + assertEquals("{\"user_uid\":\"$USER_A\"}", bootstrap.body.readUtf8()) + assertNull(bootstrap.getHeader(REMOTE_CONFIG_SESSION_HEADER)) + + val snapshot = server.takeRequest() + assertEquals("POST", snapshot.method) + assertEquals("/v3/remote-config-v2/snapshot", snapshot.path) + assertEquals("Bearer $PROJECT_TOKEN", snapshot.getHeader("Authorization")) + assertEquals(SESSION_TOKEN, snapshot.getHeader(REMOTE_CONFIG_SESSION_HEADER)) + assertNull(snapshot.getHeader("If-None-Match")) + assertEquals( + "{\"client_context\":{\"platform\":\"android\",\"app_version\":\"1.2.3\"," + + "\"os_version\":\"14\",\"sdk_version\":\"9.7.0\",\"locale\":\"en_US\"," + + "\"device_model\":\"Pixel 8\",\"device_installed_at\":1577836800}}", + snapshot.body.readUtf8(), + ) + assertTrue(response is RemoteConfigFetchResponse.Success) + } + + @Test + fun `conditional validator is forwarded verbatim as If-None-Match`() { + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(MockResponse().setResponseCode(304).setHeader("ETag", SNAPSHOT_ETAG)) + + val response = fetch(RemoteConfigFetchRequest(ifNoneMatch = SNAPSHOT_ETAG)) + + server.takeRequest() + assertEquals(SNAPSHOT_ETAG, server.takeRequest().getHeader("If-None-Match")) + assertEquals(RemoteConfigFetchResponse.NotModified(SNAPSHOT_ETAG), response) + } + + @Test + fun `200 hands back the exact response bytes and etag`() { + // Deliberately non-canonical: padded whitespace, an escaped code point and a raw + // multi-byte character. Any re-encode or charset round trip changes these bytes and + // therefore the sha256 the ETag pins. + val body = ("{ \"schema_version\" : 1, \"note\":\"\\u00e9 café \\ud83d\\ude00\"," + + "\"trailing\": true }").toByteArray(Charsets.UTF_8) + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(body, SNAPSHOT_ETAG)) + + val response = fetch(RemoteConfigFetchRequest()) + + val success = response as RemoteConfigFetchResponse.Success + assertArrayEquals(body, success.body) + assertEquals(SNAPSHOT_ETAG, success.etag) + } + + @Test + fun `200 without a strong validator is a typed failure`() { + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) + } + + @Test + fun `snapshot 401 re-bootstraps once and retries successfully`() { + persistSession(KEY_A, "stale-token") + server.enqueue(MockResponse().setResponseCode(401).setBody("{\"error\":\"unauthorized\"}")) + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + + val response = fetch(RemoteConfigFetchRequest()) + + assertTrue(response is RemoteConfigFetchResponse.Success) + val first = server.takeRequest() + assertEquals("/v3/remote-config-v2/snapshot", first.path) + assertEquals("stale-token", first.getHeader(REMOTE_CONFIG_SESSION_HEADER)) + assertEquals("/v3/remote-config-v2/session", server.takeRequest().path) + val retry = server.takeRequest() + assertEquals("/v3/remote-config-v2/snapshot", retry.path) + assertEquals(SESSION_TOKEN, retry.getHeader(REMOTE_CONFIG_SESSION_HEADER)) + assertEquals(3, server.requestCount) + } + + @Test + fun `two consecutive 401s fail typed without looping`() { + persistSession(KEY_A, "stale-token") + server.enqueue(MockResponse().setResponseCode(401)) + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(MockResponse().setResponseCode(401)) + + val response = fetch(RemoteConfigFetchRequest()) + + assertEquals(RemoteConfigFetchResponse.Failure(statusCode = 401), response) + assertEquals(3, server.requestCount) + assertNull(store().load(KEY_A)) + } + + @Test + fun `a freshly minted session never re-bootstraps on 401`() { + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(MockResponse().setResponseCode(401)) + + val response = fetch(RemoteConfigFetchRequest()) + + assertEquals(RemoteConfigFetchResponse.Failure(statusCode = 401), response) + assertEquals(2, server.requestCount) + } + + @Test + fun `bootstrap 404 and 503 surface as typed failures`() { + server.enqueue(MockResponse().setResponseCode(404).setBody("{\"error\":\"not found\"}")) + assertEquals(RemoteConfigFetchResponse.Failure(statusCode = 404), fetch(RemoteConfigFetchRequest())) + + server.enqueue(MockResponse().setResponseCode(503).setBody("{\"error\":\"unavailable\"}")) + assertEquals(RemoteConfigFetchResponse.Failure(statusCode = 503), fetch(RemoteConfigFetchRequest())) + } + + @Test + fun `snapshot 404 and 503 surface as typed failures and 503 honours Retry-After`() { + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(MockResponse().setResponseCode(404)) + assertEquals(RemoteConfigFetchResponse.Failure(statusCode = 404), fetch(RemoteConfigFetchRequest())) + + server.enqueue(MockResponse().setResponseCode(503).setHeader("Retry-After", "7")) + assertEquals( + RemoteConfigFetchResponse.Failure(statusCode = 503, retryAfterMillis = 7_000), + fetch(RemoteConfigFetchRequest()), + ) + } + + @Test + fun `a broken connection is an untyped failure rather than a crash`() { + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)) + + assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) + } + + @Test + fun `session is persisted per identity scope and never reused after an identity change`() { + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + val transport = transport() + fetch(RemoteConfigFetchRequest(), transport) + server.takeRequest() + server.takeRequest() + val keysAfterFirstIdentity = cache.strings.keys.toSet() + assertEquals(1, keysAfterFirstIdentity.size) + assertFalse(keysAfterFirstIdentity.single().contains(USER_A)) + assertFalse(keysAfterFirstIdentity.single().contains(PROJECT_TOKEN)) + + identity = identityFor(SCOPE_B, USER_B) + server.enqueue(sessionResponse(OTHER_SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + fetch(RemoteConfigFetchRequest(), transport) + + val bootstrap = server.takeRequest() + assertEquals("/v3/remote-config-v2/session", bootstrap.path) + assertEquals("{\"user_uid\":\"$USER_B\"}", bootstrap.body.readUtf8()) + val snapshot = server.takeRequest() + assertEquals(OTHER_SESSION_TOKEN, snapshot.getHeader(REMOTE_CONFIG_SESSION_HEADER)) + assertEquals(2, cache.strings.size) + assertEquals(SESSION_TOKEN, store().load(KEY_A)?.token) + assertEquals(OTHER_SESSION_TOKEN, store().load(KEY_B)?.token) + } + + @Test + fun `a persisted session is reused without another bootstrap until it expires`() { + persistSession(KEY_A, SESSION_TOKEN, expiresAtMillis = clock.now + 3_600_000) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + + assertTrue(fetch(RemoteConfigFetchRequest()) is RemoteConfigFetchResponse.Success) + assertEquals(1, server.requestCount) + assertEquals("/v3/remote-config-v2/snapshot", server.takeRequest().path) + } + + @Test + fun `an expired persisted session is dropped and re-bootstrapped`() { + persistSession(KEY_A, "expired-token", expiresAtMillis = clock.now - 1) + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + + assertTrue(fetch(RemoteConfigFetchRequest()) is RemoteConfigFetchResponse.Success) + assertEquals("/v3/remote-config-v2/session", server.takeRequest().path) + assertEquals(SESSION_TOKEN, server.takeRequest().getHeader(REMOTE_CONFIG_SESSION_HEADER)) + } + + @Test + fun `device_installed_at is unchanged across a simulated logout`() { + // Logout mints a brand new anonymous uid and therefore a brand new snapshot scope. The + // device install date must not move with it: the server takes + // min(device_installed_at, client.created_at), so a moving value would make a long-time + // user look brand new to "new users" targeting. + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + fetch(RemoteConfigFetchRequest()) + server.takeRequest() + val beforeLogout = server.takeRequest().body.readUtf8() + + identity = identityFor(SCOPE_B, USER_B) + server.enqueue(sessionResponse(OTHER_SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + fetch(RemoteConfigFetchRequest()) + server.takeRequest() + val afterLogout = server.takeRequest().body.readUtf8() + + assertTrue(beforeLogout.contains("\"device_installed_at\":1577836800")) + assertEquals(beforeLogout, afterLogout) + } + + @Test + fun `neither the project token nor the session token is ever logged`() { + persistSession(KEY_A, "stale-token") + server.enqueue(MockResponse().setResponseCode(401)) + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(MockResponse().setResponseCode(401)) + fetch(RemoteConfigFetchRequest()) + + server.enqueue(MockResponse().setResponseCode(404)) + fetch(RemoteConfigFetchRequest()) + + assertTrue(logger.messages.isNotEmpty()) + logger.messages.forEach { message -> + assertFalse(message, message.contains(PROJECT_TOKEN)) + assertFalse(message, message.contains(SESSION_TOKEN)) + assertFalse(message, message.contains("stale-token")) + } + } + + @Test + fun `an unaddressable identity fails closed without touching the network`() { + identity = null + assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) + assertEquals(0, server.requestCount) + } + + @Test + fun `a client context the gateway would reject fails closed without touching the network`() { + clientContext = CLIENT_CONTEXT.copy(deviceInstalledAtSeconds = -1) + assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) + assertEquals(0, server.requestCount) + } + + @Test + fun `a bootstrap response with an unusable token is rejected without being persisted`() { + server.enqueue(sessionResponse(" padded-token ")) + + assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) + assertNull(store().load(KEY_A)) + // No snapshot may be attempted with a token we refused. + assertEquals(1, server.requestCount) + } + + @Test + fun `a token that is not a legal header value is refused instead of thrown`() { + // OkHttp throws IllegalArgumentException for a non-printable header value, and its message + // quotes the value — i.e. the credential — so this must never reach Request.Builder. + server.enqueue(sessionResponse("session\u0001secret")) + + assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) + assertEquals(1, server.requestCount) + assertNull(store().load(KEY_A)) + logger.messages.forEach { assertFalse(it, it.contains("secret")) } + } + + @Test + fun `a project token that is not a legal header value fails closed before any request`() { + identity = RemoteConfigTransportIdentity(SCOPE_A, "project\u0001secret", USER_A) + + assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) + assertEquals(0, server.requestCount) + } + + @Test + fun `a fractional or lowercase RFC3339 expiry is understood and persisted`() { + // Go marshals time.Time as RFC3339Nano with trailing zeros stripped, so the fraction is + // 0-9 digits wide; RFC 3339 also permits a lowercase t and z. + server.enqueue(sessionResponseWithExpiry("2030-01-01t00:00:00.123456789z")) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + + assertTrue(fetch(RemoteConfigFetchRequest()) is RemoteConfigFetchResponse.Success) + + assertEquals(1_893_456_000_123, store().load(KEY_A)?.expiresAtMillis) + } + + @Test + fun `an over-budget snapshot body is refused rather than allocated`() { + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(ByteArray(65) { '{'.code.toByte() }, SNAPSHOT_ETAG)) + + assertEquals( + RemoteConfigFetchResponse.Failure(), + fetch(RemoteConfigFetchRequest(), transport(maxSnapshotBodyBytes = 64)), + ) + } + + @Test + fun `an empty 200 body is a typed failure rather than an empty admission`() { + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(MockResponse().setResponseCode(200).setHeader("ETag", SNAPSHOT_ETAG)) + + assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) + } + + @Test + fun `one transport reuses its in-memory session across fetches`() { + val transport = transport(sessionStore = RefusingSessionStore()) + server.enqueue(sessionResponse(SESSION_TOKEN)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + + assertTrue(fetch(RemoteConfigFetchRequest(), transport) is RemoteConfigFetchResponse.Success) + assertTrue(fetch(RemoteConfigFetchRequest(), transport) is RemoteConfigFetchResponse.Success) + + // Bootstrap, snapshot, snapshot: the second fetch reused the cached session even though + // the durable store refuses to keep anything. + assertEquals(3, server.requestCount) + assertEquals("/v3/remote-config-v2/session", server.takeRequest().path) + assertEquals("/v3/remote-config-v2/snapshot", server.takeRequest().path) + assertEquals("/v3/remote-config-v2/snapshot", server.takeRequest().path) + } + + @Test + fun `concurrent fetches on one transport each get exactly one answer`() { + server.dispatcher = PathDispatcher() + val transport = transport() + val start = CountDownLatch(1) + val done = CountDownLatch(THREADS) + val responses = Collections.synchronizedList(mutableListOf()) + repeat(THREADS) { + Thread { + start.await() + transport.fetch(RemoteConfigFetchRequest()) { response -> + responses += response + done.countDown() + } + }.start() + } + start.countDown() + + assertTrue(done.await(AWAIT_SECONDS, TimeUnit.SECONDS)) + assertEquals(THREADS, responses.size) + responses.forEach { assertTrue(it.toString(), it is RemoteConfigFetchResponse.Success) } + } + + @Test + fun `a session with an unparsable expiry serves the fetch but is not persisted`() { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + "{\"session_token\":\"$SESSION_TOKEN\",\"project_id\":42," + + "\"environment\":\"prod\",\"expires_at\":\"x\"}", + ), + ) + server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) + + assertTrue(fetch(RemoteConfigFetchRequest()) is RemoteConfigFetchResponse.Success) + server.takeRequest() + assertEquals(SESSION_TOKEN, server.takeRequest().getHeader(REMOTE_CONFIG_SESSION_HEADER)) + assertNull(store().load(KEY_A)) + } + + private fun fetch( + request: RemoteConfigFetchRequest, + transport: RemoteConfigGatewayTransport = transport(), + ): RemoteConfigFetchResponse { + val latch = CountDownLatch(1) + var received: RemoteConfigFetchResponse? = null + transport.fetch(request) { response -> + received = response + latch.countDown() + } + assertTrue("transport did not answer in time", latch.await(AWAIT_SECONDS, TimeUnit.SECONDS)) + return requireNotNull(received) + } + + private fun transport( + sessionStore: RemoteConfigSessionStore = store(), + maxSnapshotBodyBytes: Long = REMOTE_CONFIG_SNAPSHOT_BODY_MAX_BYTES, + ) = RemoteConfigGatewayTransport( + callFactory = client, + baseUrlProvider = { server.url("/").toString() }, + identityProvider = { identity }, + clientContextProvider = { clientContext }, + sessionStore = sessionStore, + clock = clock, + moshi = Moshi.Builder().build(), + logger = logger, + maxSnapshotBodyBytes = maxSnapshotBodyBytes, + ) + + private fun store() = PersistentRemoteConfigSessionStore(cache, Moshi.Builder().build()) + + private fun persistSession( + key: RemoteConfigSessionKey, + token: String, + expiresAtMillis: Long = clock.now + 3_600_000, + ) { + assertTrue( + store().save( + key, + RemoteConfigGatewaySession( + token = token, + projectId = 42, + environment = "prod", + expiresAtMillis = expiresAtMillis, + ), + ), + ) + } + + private fun sessionResponse(token: String) = MockResponse() + .setResponseCode(200) + .setHeader("Cache-Control", "private, no-store") + .setBody( + "{\"session_token\":\"$token\",\"project_id\":42,\"environment\":\"prod\"," + + "\"expires_at\":\"2030-01-01T00:00:00Z\"}", + ) + + private fun sessionResponseWithExpiry(expiresAt: String) = MockResponse() + .setResponseCode(200) + .setBody( + "{\"session_token\":\"$SESSION_TOKEN\",\"project_id\":42,\"environment\":\"prod\"," + + "\"expires_at\":\"$expiresAt\"}", + ) + + private fun snapshotResponse(body: ByteArray, etag: String) = MockResponse() + .setResponseCode(200) + .setHeader("ETag", etag) + .setHeader("Content-Type", "application/json") + .setBody(Buffer().write(body)) + + private fun identityFor(scope: RemoteConfigSnapshotScope, userUid: String) = + RemoteConfigTransportIdentity(scope, PROJECT_TOKEN, userUid) + + /** Answers by path so concurrent calls are not order-coupled. */ + private inner class PathDispatcher : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + if (request.path.orEmpty().endsWith("/session")) { + sessionResponse(SESSION_TOKEN) + } else { + snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG) + } + } + + private class RefusingSessionStore : RemoteConfigSessionStore { + override fun load(key: RemoteConfigSessionKey): RemoteConfigGatewaySession? = null + override fun save(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession) = false + override fun clear(key: RemoteConfigSessionKey) = false + } + + private class MutableClock(var now: Long) : RemoteConfigFetchClock { + override fun nowMillis(): Long = now + } + + private class RecordingLogger : Logger { + val messages = mutableListOf() + override fun error(message: String) { messages += message } + override fun warn(message: String) { messages += message } + override fun release(message: String) { messages += message } + override fun debug(message: String) { messages += message } + } + + private class MapCache : Cache { + val strings = mutableMapOf() + + override fun putInt(key: String, value: Int) = Unit + override fun getInt(key: String, defValue: Int): Int = defValue + override fun getBool(key: String, defValue: Boolean): Boolean = defValue + override fun putBool(key: String, value: Boolean) = Unit + override fun putFloat(key: String, value: Float) = Unit + override fun getFloat(key: String, defValue: Float): Float = defValue + override fun putLong(key: String, value: Long) = Unit + override fun getLong(key: String, defValue: Long): Long = defValue + override fun putString(key: String, value: String?) { strings[key] = value } + override fun getString(key: String, defValue: String?): String? = strings[key] ?: defValue + override fun remove(key: String) { strings.remove(key) } + + override fun updateStringsDurably(values: Map, removedKeys: Set): Boolean { + removedKeys.forEach(strings::remove) + strings.putAll(values) + return true + } + + override fun putObject(key: String, value: T, adapter: JsonAdapter) = Unit + override fun getObject(key: String, adapter: JsonAdapter): T? = null + } + + private companion object { + const val AWAIT_SECONDS = 10L + const val THREADS = 4 + const val PROJECT_TOKEN = "project-key-secret" + const val SESSION_TOKEN = "qrcs1.session-secret" + const val OTHER_SESSION_TOKEN = "qrcs1.other-session-secret" + const val USER_A = "QON_anon_a" + const val USER_B = "QON_anon_b" + val SCOPE_A = RemoteConfigSnapshotScope("project", "env-production", USER_A) + val SCOPE_B = RemoteConfigSnapshotScope("project", "env-production", USER_B) + val KEY_A = RemoteConfigSessionKey(SCOPE_A, USER_A) + val KEY_B = RemoteConfigSessionKey(SCOPE_B, USER_B) + val SNAPSHOT_BODY = "{\"schema_version\":1}".toByteArray(Charsets.UTF_8) + const val SNAPSHOT_ETAG = "\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"" + val CLIENT_CONTEXT = RemoteConfigClientContext( + platform = "android", + appVersion = "1.2.3", + osVersion = "14", + sdkVersion = "9.7.0", + locale = "en_US", + deviceModel = "Pixel 8", + deviceInstalledAtSeconds = 1_577_836_800, + ) + } +}