From 6f2bdc993d29a1081a14cf05e4e21775397bca60 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Fri, 7 Aug 2026 11:28:06 +0300 Subject: [PATCH 1/2] feat(remote-config): bind the fetch policy to the v2 gateway Implement the internal Remote Config v2 transport adapter behind the existing RemoteConfigFetchTransport seam, so the resilient fetch policy can talk to the dark gateway routes without any public API change. - Bootstrap on a missing or expired session (POST /v3/remote-config-v2/session) and read the snapshot (POST /v3/remote-config-v2/snapshot) with the session header and the coordinator's exact If-None-Match validator. - A snapshot 401 drops the session and re-bootstraps exactly once; a second 401 is a typed failure, so the flow cannot loop. - The response body reaches durable admission as the exact bytes received, paired with the exact ETag: no decode, re-encode or charset round trip. - Sessions are stored per identity scope under a salted digest key, so an identity change addresses a different record and can never reuse the previous identity's token. Neither token is ever logged. - device_installed_at is sourced from PackageManager.firstInstallTime, a device fact that survives logout: the server takes min(device_installed_at, client.created_at), so a moving value would make a long-time user look new. Tests use MockWebServer (new test-only dependency, pinned to the SDK's OkHttp version) and cover the wire shape of both routes, byte equality on a non-canonical body, 304, 401 recovery and the no-loop bound, 404/503, session scoping, and the timeout path through the real coordinator. Claude-Session: https://claude.ai/code/session_018pHXqfbxkMQJFzUZ3jW4A8 --- sdk/build.gradle | 6 +- ...DeviceRemoteConfigClientContextProvider.kt | 65 +++ .../RemoteConfigGatewaySession.kt | 154 ++++++ .../RemoteConfigGatewayTransport.kt | 512 ++++++++++++++++++ ...ceRemoteConfigClientContextProviderTest.kt | 61 +++ .../PersistentRemoteConfigSessionStoreTest.kt | 108 ++++ ...teConfigGatewayTransportCoordinatorTest.kt | 262 +++++++++ .../RemoteConfigGatewayTransportTest.kt | 443 +++++++++++++++ 8 files changed, 1610 insertions(+), 1 deletion(-) create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/DeviceRemoteConfigClientContextProvider.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewaySession.kt create mode 100644 sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransport.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/DeviceRemoteConfigClientContextProviderTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigSessionStoreTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportCoordinatorTest.kt create mode 100644 sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportTest.kt diff --git a/sdk/build.gradle b/sdk/build.gradle index 1a0594a67..53dcee0bd 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -84,7 +84,8 @@ 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", + mockWebServer : "com.squareup.okhttp3:mockwebserver:$okhttp_version" ] lifecycle = [ @@ -164,6 +165,9 @@ dependencies { // Mockito testImplementation 'org.mockito:mockito-core:4.3.1' + // MockWebServer (HTTP contract tests, pinned to the SDK's OkHttp version) + 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 000000000..8e447c05e --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/DeviceRemoteConfigClientContextProvider.kt @@ -0,0 +1,65 @@ +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 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 + } + + private fun locale(): String { + val locale = Locale.getDefault() + val language = locale.language.takeIf { it.isNotEmpty() } ?: return UNKNOWN + val country = locale.country + return if (country.isEmpty()) language else "${language}_$country" + } +} 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 000000000..e23f143f7 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewaySession.kt @@ -0,0 +1,154 @@ +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 +} + +internal interface RemoteConfigSessionStore { + fun load(scope: RemoteConfigSnapshotScope): RemoteConfigGatewaySession? + fun save(scope: RemoteConfigSnapshotScope, session: RemoteConfigGatewaySession): Boolean + fun clear(scope: RemoteConfigSnapshotScope): 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(scope: RemoteConfigSnapshotScope): RemoteConfigGatewaySession? { + val key = remoteConfigSessionStorageKey(scope) + val raw = try { + cache.getString(key, 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(key) + return null + } + return RemoteConfigGatewaySession( + token = persisted.token, + projectId = persisted.projectId, + environment = persisted.environment, + expiresAtMillis = persisted.expiresAtMillis, + ) + } + + @Synchronized + @Suppress("ReturnCount") + override fun save(scope: RemoteConfigSnapshotScope, 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(scope) to raw), + removedKeys = emptySet(), + ) + } catch (_: Exception) { + false + } + } + + @Synchronized + override fun clear(scope: RemoteConfigSnapshotScope): Boolean = try { + cache.updateStringsDurably(emptyMap(), setOf(remoteConfigSessionStorageKey(scope))) + } 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(scope: RemoteConfigSnapshotScope): String { + val digest = MessageDigest.getInstance("SHA-256") + digest.updateLengthPrefixed("remote-config-gateway-session-v1".encodeToByteArray()) + digest.updateLengthPrefixed(scope.projectKey.encodeToByteArray()) + digest.updateLengthPrefixed(scope.environment.encodeToByteArray()) + digest.updateLengthPrefixed(scope.canonicalUserId.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 000000000..4d2a919ab --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransport.kt @@ -0,0 +1,512 @@ +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 java.io.IOException +import java.text.ParsePosition +import java.text.SimpleDateFormat +import java.util.Locale +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" + +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 + +/** + * 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. + */ +internal data class RemoteConfigTransportIdentity( + val scope: RemoteConfigSnapshotScope, + val projectToken: String, + val userUid: String, +) { + internal fun isValid(): Boolean = projectToken.isNotEmpty() && + projectToken.trim() == projectToken && + 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 [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, +) : 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 cachedScope: RemoteConfigSnapshotScope? = 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.scope) + 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 url = resolve(REMOTE_CONFIG_SNAPSHOT_PATH) + val body = try { + snapshotRequestAdapter.toJson(RemoteConfigSnapshotRequest(context.toWire())) + } catch (_: Exception) { + null + } + if (url == null || body == null) { + deliver(RemoteConfigFetchResponse.Failure()) + return + } + val httpRequest = baseRequest(url, identity, body) + .header(REMOTE_CONFIG_SESSION_HEADER, session.token) + .apply { + request.ifNoneMatch + ?.takeIf { it.isNotEmpty() && it.trim() == it } + ?.let { header("If-None-Match", it) } + } + .build() + 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.scope) + if (!allowReBootstrap) { + logger.debug("Remote Config v2 snapshot stayed unauthorized after re-bootstrap") + deliver(RemoteConfigFetchResponse.Failure(statusCode = outcome.code)) + return + } + reBootstrapOnce(identity, context, request, deliver) + } + else -> deliver(outcome.asFailure()) + } + } + + private fun reBootstrapOnce( + identity: RemoteConfigTransportIdentity, + context: RemoteConfigClientContext, + request: RemoteConfigFetchRequest, + deliver: SingleDelivery, + ) { + mint(identity, deliver) { session -> + requestSnapshot(identity, context, session, request, deliver, allowReBootstrap = false) + } + } + + private fun mint( + identity: RemoteConfigTransportIdentity, + deliver: SingleDelivery, + onMinted: (RemoteConfigGatewaySession) -> Unit, + ) { + val url = resolve(REMOTE_CONFIG_SESSION_PATH) + val body = try { + bootstrapRequestAdapter.toJson(RemoteConfigSessionRequest(identity.userUid)) + } catch (_: Exception) { + null + } + if (url == null || body == null) { + deliver(RemoteConfigFetchResponse.Failure()) + return + } + enqueue(baseRequest(url, identity, body).build(), 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.scope, session) + onMinted(session) + } + } + + private fun baseRequest( + url: HttpUrl, + identity: RemoteConfigTransportIdentity, + body: String, + ): Request.Builder = Request.Builder() + .url(url) + .header("Authorization", "Bearer ${identity.projectToken}") + .header("Content-Type", JSON_CONTENT_TYPE) + // The gateway answers `private, no-store`; declaring it on the request as well keeps a + // shared OkHttp cache from ever synthesising a body the strict parser never saw. + .header("Cache-Control", "no-store") + .post(RequestBody.create(JSON_MEDIA_TYPE, body.toByteArray(Charsets.UTF_8))) + + /** + * 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(), + // `bytes()` is the raw octet stream: no charset decode, no re-encode, no JSON round trip. + body = if (code() == HTTP_NOT_MODIFIED) null else body()?.bytes(), + etag = header("ETag"), + retryAfterMillis = header("Retry-After").parseRetryAfterMillis(), + ) + + @Suppress("ReturnCount") + private fun loadUsableSession(scope: RemoteConfigSnapshotScope): RemoteConfigGatewaySession? { + val now = nowMillis() + synchronized(lock) { + if (cachedScope == scope) { + cachedSession?.let { return it.takeIf { session -> session.isUsable(now) } } + } + } + val persisted = try { + sessionStore.load(scope) + } catch (_: Exception) { + null + } ?: return null + if (!persisted.isUsable(now)) { + forgetSession(scope) + return null + } + synchronized(lock) { + cachedScope = scope + cachedSession = persisted + } + return persisted + } + + private fun rememberSession(scope: RemoteConfigSnapshotScope, session: RemoteConfigGatewaySession) { + synchronized(lock) { + cachedScope = scope + 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(scope, session) + } catch (_: Exception) { + // The in-memory session still serves this process; the next cold start re-bootstraps. + } + } + + private fun forgetSession(scope: RemoteConfigSnapshotScope) { + synchronized(lock) { + if (cachedScope == scope) { + cachedSession = null + cachedScope = null + } + } + try { + sessionStore.clear(scope) + } catch (_: Exception) { + // 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 (_: Exception) { + null + } ?: return null + val token = parsed.sessionToken ?: return null + if (token.isEmpty() || token.trim() != token || + token.toByteArray(Charsets.UTF_8).size > REMOTE_CONFIG_SESSION_TOKEN_HEADER_MAX_BYTES + ) { + return null + } + val projectId = parsed.projectId ?: return null + val environment = parsed.environment?.takeIf { it.isNotEmpty() } ?: return null + if (projectId <= 0) 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 resolve(path: String): HttpUrl? = try { + HttpUrl.parse(baseUrlProvider())?.newBuilder()?.addPathSegments(path)?.build() + } catch (_: Exception) { + null + } + + private fun nowMillis(): Long = try { + clock.nowMillis().coerceAtLeast(0) + } catch (_: Exception) { + 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 || validator.isNullOrEmpty()) { + // A 200 without a strong validator cannot be admitted, and it is not 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, + ) + } +} + +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 + } + +private fun String?.parseRfc3339Millis(): Long? { + val value = this?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val normalized = value.replace("Z", "+0000").replace(Regex("([+\\-]\\d{2}):(\\d{2})$"), "$1$2") + return RFC3339_FORMATS.firstNotNullOfOrNull { pattern -> + val format = SimpleDateFormat(pattern, Locale.US).apply { + isLenient = false + timeZone = TimeZone.getTimeZone("UTC") + } + val position = ParsePosition(0) + val parsed = format.parse(normalized, position) + parsed?.takeIf { position.index == normalized.length }?.time + } +} + +private val RFC3339_FORMATS = listOf( + "yyyy-MM-dd'T'HH:mm:ssZ", + "yyyy-MM-dd'T'HH:mm:ss.SSSZ", +) + +@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 000000000..e716824c7 --- /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 000000000..1d4990579 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/PersistentRemoteConfigSessionStoreTest.kt @@ -0,0 +1,108 @@ +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 otherIdentity = RemoteConfigSnapshotScope("project-secret", "env-production", "other-secret") + 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(scope, session)) + + val persistedKey = cache.strings.keys.single() + assertFalse(persistedKey.contains("project-secret")) + assertFalse(persistedKey.contains("customer-secret")) + assertEquals(session, store(cache).load(scope)) + } + + @Test + fun `an identity change addresses a different record and never reads the previous token`() { + val cache = MapCache() + assertTrue(store(cache).save(scope, 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(scope)?.token) + assertEquals("qrcs1.other", store(cache).load(otherIdentity)?.token) + } + + @Test + fun `clearing drops only the addressed identity`() { + val cache = MapCache() + val sessionStore = store(cache) + assertTrue(sessionStore.save(scope, session)) + assertTrue(sessionStore.save(otherIdentity, session.copy(token = "qrcs1.other"))) + + assertTrue(sessionStore.clear(scope)) + + assertNull(sessionStore.load(scope)) + 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(scope, 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(scope)) + 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(scope, session.copy(token = ""))) + assertFalse(store(cache).save(scope, session.copy(projectId = 0))) + assertFalse(store(cache).save(scope, 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 000000000..4b48dd5d3 --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportCoordinatorTest.kt @@ -0,0 +1,262 @@ +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.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 while the socket is left alone`() { + 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) + } + + 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(scope: RemoteConfigSnapshotScope) = sessions[scope] + + @Synchronized + override fun save(scope: RemoteConfigSnapshotScope, session: RemoteConfigGatewaySession): Boolean { + sessions[scope] = session + return true + } + + @Synchronized + override fun clear(scope: RemoteConfigSnapshotScope): Boolean { + sessions.remove(scope) + 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 000000000..0e94949eb --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/remoteconfig/RemoteConfigGatewayTransportTest.kt @@ -0,0 +1,443 @@ +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.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.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +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 the exact response bytes and etag to the admission seam`() { + // 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(SCOPE_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(SCOPE_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(SCOPE_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(SCOPE_A)?.token) + assertEquals(OTHER_SESSION_TOKEN, store().load(SCOPE_B)?.token) + } + + @Test + fun `a persisted session is reused without another bootstrap until it expires`() { + persistSession(SCOPE_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(SCOPE_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(SCOPE_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(SCOPE_A)) + } + + @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(SCOPE_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() = RemoteConfigGatewayTransport( + callFactory = client, + baseUrlProvider = { server.url("/").toString() }, + identityProvider = { identity }, + clientContextProvider = { clientContext }, + sessionStore = store(), + clock = clock, + moshi = Moshi.Builder().build(), + logger = logger, + ) + + private fun store() = PersistentRemoteConfigSessionStore(cache, Moshi.Builder().build()) + + private fun persistSession( + scope: RemoteConfigSnapshotScope, + token: String, + expiresAtMillis: Long = clock.now + 3_600_000, + ) { + assertTrue( + store().save( + scope, + 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 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) + + 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 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 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, + ) + } +} From e7c7d7783f70327fa08005ab450e715774d026c3 Mon Sep 17 00:00:00 2001 From: Daniil Fadeev Date: Fri, 7 Aug 2026 11:39:57 +0300 Subject: [PATCH 2/2] review: harden the v2 gateway transport against review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the adapter found four real defects and several weaker-than-claimed tests. All are addressed here. - A session or project token containing a non-printable byte reached Request.Builder.header, which throws — on an OkHttp dispatcher thread, from the mint callback and the 401 retry. That both stranded the coordinator's waiter (no completion) and put the credential into the exception message, so a token could reach a crash reporter. Tokens are now validated as HTTP header values, and request building is failure-typed instead of throwing. - The RFC3339 expiry parser only accepted exactly three fractional digits and an uppercase T/Z, so a Go gateway's RFC3339Nano timestamp parsed as "unknown" and every fetch silently re-bootstrapped forever. Replaced with a parser that accepts 0-9 fractional digits, either case, and numeric offsets. - A dead in-memory session shadowed the durable record and skipped its cleanup. - The session store is now keyed by the anonymous uid the session was minted for, not only by the snapshot scope, so a re-minted uid under an unchanged scope can never replay the previous identity's token. Also: bounded (and injectable) snapshot body read, an empty 200 body is a typed failure rather than an empty admission, Accept header, BCP-47 locale so Hebrew and Indonesian are not sent as the legacy iw/in codes, and MockWebServer pinned to the OkHttp version that actually resolves rather than the declared one. New coverage: non-printable tokens, fractional/lowercase expiry, over-budget and empty bodies, in-memory session reuse, concurrent fetches each answered exactly once, and a re-minted uid addressing a different session record. Claude-Session: https://claude.ai/code/session_018pHXqfbxkMQJFzUZ3jW4A8 --- sdk/build.gradle | 7 +- ...DeviceRemoteConfigClientContextProvider.kt | 17 +- .../RemoteConfigGatewaySession.kt | 44 ++-- .../RemoteConfigGatewayTransport.kt | 240 +++++++++++------- .../PersistentRemoteConfigSessionStoreTest.kt | 43 +++- ...teConfigGatewayTransportCoordinatorTest.kt | 18 +- .../RemoteConfigGatewayTransportTest.kt | 157 ++++++++++-- 7 files changed, 372 insertions(+), 154 deletions(-) diff --git a/sdk/build.gradle b/sdk/build.gradle index 53dcee0bd..c095bddbb 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -85,7 +85,10 @@ ext { core : "com.squareup.retrofit2:retrofit:$retrofit_version", moshiConverter : "com.squareup.retrofit2:converter-moshi:$retrofit_version", okhttp : "com.squareup.okhttp3:okhttp:$okhttp_version", - mockWebServer : "com.squareup.okhttp3:mockwebserver:$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 = [ @@ -165,7 +168,7 @@ dependencies { // Mockito testImplementation 'org.mockito:mockito-core:4.3.1' - // MockWebServer (HTTP contract tests, pinned to the SDK's OkHttp version) + // MockWebServer (HTTP contract tests) testImplementation network.mockWebServer testImplementation 'androidx.test:core:1.5.0' 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 index 8e447c05e..9794f8f52 100644 --- 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 @@ -7,6 +7,7 @@ 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 /** @@ -56,10 +57,18 @@ internal class DeviceRemoteConfigClientContextProvider( 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 locale = Locale.getDefault() - val language = locale.language.takeIf { it.isNotEmpty() } ?: return UNKNOWN - val country = locale.country - return if (country.isEmpty()) language else "${language}_$country" + 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 index e23f143f7..6ec981174 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 @@ -30,10 +30,23 @@ internal data class RemoteConfigGatewaySession( 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(scope: RemoteConfigSnapshotScope): RemoteConfigGatewaySession? - fun save(scope: RemoteConfigSnapshotScope, session: RemoteConfigGatewaySession): Boolean - fun clear(scope: RemoteConfigSnapshotScope): Boolean + fun load(key: RemoteConfigSessionKey): RemoteConfigGatewaySession? + fun save(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession): Boolean + fun clear(key: RemoteConfigSessionKey): Boolean } /** @@ -51,10 +64,10 @@ internal class PersistentRemoteConfigSessionStore( @Synchronized @Suppress("ReturnCount") - override fun load(scope: RemoteConfigSnapshotScope): RemoteConfigGatewaySession? { - val key = remoteConfigSessionStorageKey(scope) + override fun load(key: RemoteConfigSessionKey): RemoteConfigGatewaySession? { + val storageKey = remoteConfigSessionStorageKey(key) val raw = try { - cache.getString(key, null) + cache.getString(storageKey, null) } catch (_: Exception) { null } ?: return null @@ -65,7 +78,7 @@ internal class PersistentRemoteConfigSessionStore( null } if (persisted == null || !persisted.isValid()) { - removeInvalid(key) + removeInvalid(storageKey) return null } return RemoteConfigGatewaySession( @@ -78,7 +91,7 @@ internal class PersistentRemoteConfigSessionStore( @Synchronized @Suppress("ReturnCount") - override fun save(scope: RemoteConfigSnapshotScope, session: RemoteConfigGatewaySession): Boolean { + override fun save(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession): Boolean { val persisted = PersistedRemoteConfigGatewaySession( version = REMOTE_CONFIG_SESSION_VERSION, token = session.token, @@ -95,7 +108,7 @@ internal class PersistentRemoteConfigSessionStore( if (raw.toByteArray(Charsets.UTF_8).size > REMOTE_CONFIG_SESSION_MAX_BYTES) return false return try { cache.updateStringsDurably( - values = mapOf(remoteConfigSessionStorageKey(scope) to raw), + values = mapOf(remoteConfigSessionStorageKey(key) to raw), removedKeys = emptySet(), ) } catch (_: Exception) { @@ -104,8 +117,8 @@ internal class PersistentRemoteConfigSessionStore( } @Synchronized - override fun clear(scope: RemoteConfigSnapshotScope): Boolean = try { - cache.updateStringsDurably(emptyMap(), setOf(remoteConfigSessionStorageKey(scope))) + override fun clear(key: RemoteConfigSessionKey): Boolean = try { + cache.updateStringsDurably(emptyMap(), setOf(remoteConfigSessionStorageKey(key))) } catch (_: Exception) { false } @@ -139,12 +152,13 @@ internal data class PersistedRemoteConfigGatewaySession( val expiresAtMillis: Long, ) -private fun remoteConfigSessionStorageKey(scope: RemoteConfigSnapshotScope): String { +private fun remoteConfigSessionStorageKey(key: RemoteConfigSessionKey): String { val digest = MessageDigest.getInstance("SHA-256") digest.updateLengthPrefixed("remote-config-gateway-session-v1".encodeToByteArray()) - digest.updateLengthPrefixed(scope.projectKey.encodeToByteArray()) - digest.updateLengthPrefixed(scope.environment.encodeToByteArray()) - digest.updateLengthPrefixed(scope.canonicalUserId.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) } } 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 4d2a919ab..3f234e4fa 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 @@ -11,16 +11,16 @@ import okhttp3.MediaType import okhttp3.Request import okhttp3.RequestBody import okhttp3.Response +import okhttp3.ResponseBody import java.io.IOException -import java.text.ParsePosition -import java.text.SimpleDateFormat -import java.util.Locale +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 @@ -30,6 +30,8 @@ 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. @@ -73,14 +75,20 @@ internal fun interface RemoteConfigClientContextProvider { * 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.trim() == projectToken && + projectToken.isHttpHeaderSafe() && userUid.isNotEmpty() && userUid.toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_USER_UID_MAX_BYTES && !userUid.contains(UNICODE_REPLACEMENT_CHARACTER) @@ -106,6 +114,10 @@ internal fun interface RemoteConfigTransportIdentityProvider { * 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. @@ -122,13 +134,14 @@ internal class RemoteConfigGatewayTransport( 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 cachedScope: RemoteConfigSnapshotScope? = null + private var cachedKey: RemoteConfigSessionKey? = null private var cachedSession: RemoteConfigGatewaySession? = null override fun fetch( @@ -143,7 +156,7 @@ internal class RemoteConfigGatewayTransport( deliver(RemoteConfigFetchResponse.Failure()) return } - val session = loadUsableSession(identity.scope) + 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. @@ -164,24 +177,23 @@ internal class RemoteConfigGatewayTransport( deliver: SingleDelivery, allowReBootstrap: Boolean, ) { - val url = resolve(REMOTE_CONFIG_SNAPSHOT_PATH) val body = try { snapshotRequestAdapter.toJson(RemoteConfigSnapshotRequest(context.toWire())) - } catch (_: Exception) { + } catch (_: Throwable) { null } - if (url == null || body == 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 } - val httpRequest = baseRequest(url, identity, body) - .header(REMOTE_CONFIG_SESSION_HEADER, session.token) - .apply { - request.ifNoneMatch - ?.takeIf { it.isNotEmpty() && it.trim() == it } - ?.let { header("If-None-Match", it) } - } - .build() enqueue(httpRequest, deliver) { outcome -> onSnapshotOutcome(identity, context, request, deliver, allowReBootstrap, outcome) } @@ -202,45 +214,36 @@ internal class RemoteConfigGatewayTransport( outcome.code == HTTP_NOT_MODIFIED -> deliver(RemoteConfigFetchResponse.NotModified(outcome.etag)) outcome.code == HTTP_UNAUTHORIZED -> { - forgetSession(identity.scope) + forgetSession(identity.sessionKey) if (!allowReBootstrap) { logger.debug("Remote Config v2 snapshot stayed unauthorized after re-bootstrap") deliver(RemoteConfigFetchResponse.Failure(statusCode = outcome.code)) return } - reBootstrapOnce(identity, context, request, deliver) + mint(identity, deliver) { session -> + requestSnapshot(identity, context, session, request, deliver, allowReBootstrap = false) + } } else -> deliver(outcome.asFailure()) } } - private fun reBootstrapOnce( - identity: RemoteConfigTransportIdentity, - context: RemoteConfigClientContext, - request: RemoteConfigFetchRequest, - deliver: SingleDelivery, - ) { - mint(identity, deliver) { session -> - requestSnapshot(identity, context, session, request, deliver, allowReBootstrap = false) - } - } - private fun mint( identity: RemoteConfigTransportIdentity, deliver: SingleDelivery, onMinted: (RemoteConfigGatewaySession) -> Unit, ) { - val url = resolve(REMOTE_CONFIG_SESSION_PATH) val body = try { bootstrapRequestAdapter.toJson(RemoteConfigSessionRequest(identity.userUid)) - } catch (_: Exception) { + } catch (_: Throwable) { null } - if (url == null || body == null) { + val httpRequest = body?.let { buildRequest(REMOTE_CONFIG_SESSION_PATH, identity, it) } + if (httpRequest == null) { deliver(RemoteConfigFetchResponse.Failure()) return } - enqueue(baseRequest(url, identity, body).build(), deliver) { outcome -> + enqueue(httpRequest, deliver) { outcome -> val session = outcome ?.takeIf { it.code == HTTP_OK } ?.body @@ -252,23 +255,38 @@ internal class RemoteConfigGatewayTransport( deliver(if (outcome?.code == HTTP_OK) RemoteConfigFetchResponse.Failure() else outcome.asFailure()) return@enqueue } - rememberSession(identity.scope, session) + rememberSession(identity.sessionKey, session) onMinted(session) } } - private fun baseRequest( - url: HttpUrl, + /** + * 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, - ): Request.Builder = Request.Builder() - .url(url) - .header("Authorization", "Bearer ${identity.projectToken}") - .header("Content-Type", JSON_CONTENT_TYPE) - // The gateway answers `private, no-store`; declaring it on the request as well keeps a - // shared OkHttp cache from ever synthesising a body the strict parser never saw. - .header("Cache-Control", "no-store") - .post(RequestBody.create(JSON_MEDIA_TYPE, body.toByteArray(Charsets.UTF_8))) + 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 @@ -309,61 +327,69 @@ internal class RemoteConfigGatewayTransport( private fun Response.toOutcome(): HttpOutcome = HttpOutcome( code = code(), - // `bytes()` is the raw octet stream: no charset decode, no re-encode, no JSON round trip. - body = if (code() == HTTP_NOT_MODIFIED) null else body()?.bytes(), + 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(scope: RemoteConfigSnapshotScope): RemoteConfigGatewaySession? { + private fun loadUsableSession(key: RemoteConfigSessionKey): RemoteConfigGatewaySession? { val now = nowMillis() - synchronized(lock) { - if (cachedScope == scope) { - cachedSession?.let { return it.takeIf { session -> session.isUsable(now) } } - } - } + 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(scope) - } catch (_: Exception) { + sessionStore.load(key) + } catch (_: Throwable) { null - } ?: return null - if (!persisted.isUsable(now)) { - forgetSession(scope) + } + if (persisted == null || !persisted.isUsable(now)) { + forgetSession(key) return null } synchronized(lock) { - cachedScope = scope + cachedKey = key cachedSession = persisted } return persisted } - private fun rememberSession(scope: RemoteConfigSnapshotScope, session: RemoteConfigGatewaySession) { + private fun rememberSession(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession) { synchronized(lock) { - cachedScope = scope + 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(scope, session) - } catch (_: Exception) { + sessionStore.save(key, session) + } catch (_: Throwable) { // The in-memory session still serves this process; the next cold start re-bootstraps. } } - private fun forgetSession(scope: RemoteConfigSnapshotScope) { + private fun forgetSession(key: RemoteConfigSessionKey) { synchronized(lock) { - if (cachedScope == scope) { + if (cachedKey == key) { cachedSession = null - cachedScope = null + cachedKey = null } } try { - sessionStore.clear(scope) - } catch (_: Exception) { + sessionStore.clear(key) + } catch (_: Throwable) { // A stale record is re-validated (and dropped again) on the next load. } } @@ -372,18 +398,13 @@ internal class RemoteConfigGatewayTransport( private fun readSession(body: ByteArray): RemoteConfigGatewaySession? { val parsed = try { bootstrapResponseAdapter.fromJson(body.toString(Charsets.UTF_8)) - } catch (_: Exception) { + } catch (_: Throwable) { null } ?: return null val token = parsed.sessionToken ?: return null - if (token.isEmpty() || token.trim() != token || - token.toByteArray(Charsets.UTF_8).size > REMOTE_CONFIG_SESSION_TOKEN_HEADER_MAX_BYTES - ) { - return null - } - val projectId = parsed.projectId ?: 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 - if (projectId <= 0) return null return RemoteConfigGatewaySession( token = token, projectId = projectId, @@ -397,15 +418,9 @@ internal class RemoteConfigGatewayTransport( private fun RemoteConfigGatewaySession.isUsable(nowMillis: Long): Boolean = isUsableAt(nowMillis + REMOTE_CONFIG_SESSION_EXPIRY_SKEW_MILLIS) - private fun resolve(path: String): HttpUrl? = try { - HttpUrl.parse(baseUrlProvider())?.newBuilder()?.addPathSegments(path)?.build() - } catch (_: Exception) { - null - } - private fun nowMillis(): Long = try { clock.nowMillis().coerceAtLeast(0) - } catch (_: Exception) { + } catch (_: Throwable) { 0 } @@ -438,8 +453,9 @@ internal class RemoteConfigGatewayTransport( fun asSuccessOrFailure(): RemoteConfigFetchResponse { val bytes = body val validator = etag - return if (bytes == null || validator.isNullOrEmpty()) { - // A 200 without a strong validator cannot be admitted, and it is not retryable. + 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) @@ -458,28 +474,56 @@ internal class RemoteConfigGatewayTransport( } } +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 value = this?.trim()?.takeIf { it.isNotEmpty() } ?: return null - val normalized = value.replace("Z", "+0000").replace(Regex("([+\\-]\\d{2}):(\\d{2})$"), "$1$2") - return RFC3339_FORMATS.firstNotNullOfOrNull { pattern -> - val format = SimpleDateFormat(pattern, Locale.US).apply { - isLenient = false - timeZone = TimeZone.getTimeZone("UTC") - } - val position = ParsePosition(0) - val parsed = format.parse(normalized, position) - parsed?.takeIf { position.index == normalized.length }?.time + 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_FORMATS = listOf( - "yyyy-MM-dd'T'HH:mm:ssZ", - "yyyy-MM-dd'T'HH:mm:ss.SSSZ", +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) 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 index 1d4990579..d462ff04d 100644 --- 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 @@ -11,7 +11,12 @@ import org.junit.Test internal class PersistentRemoteConfigSessionStoreTest { private val scope = RemoteConfigSnapshotScope("project-secret", "env-production", "customer-secret") - private val otherIdentity = RemoteConfigSnapshotScope("project-secret", "env-production", "other-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, @@ -22,36 +27,48 @@ internal class PersistentRemoteConfigSessionStoreTest { @Test fun `session is durably scoped and survives a new store instance`() { val cache = MapCache() - assertTrue(store(cache).save(scope, session)) + assertTrue(store(cache).save(key, session)) val persistedKey = cache.strings.keys.single() assertFalse(persistedKey.contains("project-secret")) assertFalse(persistedKey.contains("customer-secret")) - assertEquals(session, store(cache).load(scope)) + 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(scope, session)) + 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(scope)?.token) + 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(scope, session)) + assertTrue(sessionStore.save(key, session)) assertTrue(sessionStore.save(otherIdentity, session.copy(token = "qrcs1.other"))) - assertTrue(sessionStore.clear(scope)) + assertTrue(sessionStore.clear(key)) - assertNull(sessionStore.load(scope)) + assertNull(sessionStore.load(key)) assertEquals("qrcs1.other", sessionStore.load(otherIdentity)?.token) } @@ -59,13 +76,13 @@ internal class PersistentRemoteConfigSessionStoreTest { fun `malformed persisted session is removed fail closed`() { val cache = MapCache() val sessionStore = store(cache) - assertTrue(sessionStore.save(scope, session)) + 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(scope)) + assertNull(sessionStore.load(key)) assertFalse(cache.strings.containsKey(persistedKey)) } @@ -73,9 +90,9 @@ internal class PersistentRemoteConfigSessionStoreTest { fun `a session that cannot be described is refused rather than half written`() { val cache = MapCache() - assertFalse(store(cache).save(scope, session.copy(token = ""))) - assertFalse(store(cache).save(scope, session.copy(projectId = 0))) - assertFalse(store(cache).save(scope, session.copy(expiresAtMillis = 0))) + 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()) } 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 4b48dd5d3..7156bbca5 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 @@ -13,6 +13,7 @@ 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 @@ -89,7 +90,7 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { } @Test - fun `a stalled gateway times out through the fetch policy while the socket is left alone`() { + fun `a stalled gateway times out through the fetch policy`() { val core = core() val coordinator = coordinator(core) coordinator.transitionTo(BINDING) @@ -105,6 +106,9 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { 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 { @@ -183,20 +187,20 @@ internal class RemoteConfigGatewayTransportCoordinatorTest { } private class InMemorySessionStore : RemoteConfigSessionStore { - private val sessions = mutableMapOf() + private val sessions = mutableMapOf() @Synchronized - override fun load(scope: RemoteConfigSnapshotScope) = sessions[scope] + override fun load(key: RemoteConfigSessionKey) = sessions[key] @Synchronized - override fun save(scope: RemoteConfigSnapshotScope, session: RemoteConfigGatewaySession): Boolean { - sessions[scope] = session + override fun save(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession): Boolean { + sessions[key] = session return true } @Synchronized - override fun clear(scope: RemoteConfigSnapshotScope): Boolean { - sessions.remove(scope) + override fun clear(key: RemoteConfigSessionKey): Boolean { + sessions.remove(key) return true } } 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 0e94949eb..d9b944211 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 @@ -5,8 +5,10 @@ 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 @@ -17,6 +19,7 @@ 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 @@ -93,7 +96,7 @@ internal class RemoteConfigGatewayTransportTest { } @Test - fun `200 hands the exact response bytes and etag to the admission seam`() { + 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. @@ -119,7 +122,7 @@ internal class RemoteConfigGatewayTransportTest { @Test fun `snapshot 401 re-bootstraps once and retries successfully`() { - persistSession(SCOPE_A, "stale-token") + 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)) @@ -139,7 +142,7 @@ internal class RemoteConfigGatewayTransportTest { @Test fun `two consecutive 401s fail typed without looping`() { - persistSession(SCOPE_A, "stale-token") + persistSession(KEY_A, "stale-token") server.enqueue(MockResponse().setResponseCode(401)) server.enqueue(sessionResponse(SESSION_TOKEN)) server.enqueue(MockResponse().setResponseCode(401)) @@ -148,7 +151,7 @@ internal class RemoteConfigGatewayTransportTest { assertEquals(RemoteConfigFetchResponse.Failure(statusCode = 401), response) assertEquals(3, server.requestCount) - assertNull(store().load(SCOPE_A)) + assertNull(store().load(KEY_A)) } @Test @@ -215,13 +218,13 @@ internal class RemoteConfigGatewayTransportTest { val snapshot = server.takeRequest() assertEquals(OTHER_SESSION_TOKEN, snapshot.getHeader(REMOTE_CONFIG_SESSION_HEADER)) assertEquals(2, cache.strings.size) - assertEquals(SESSION_TOKEN, store().load(SCOPE_A)?.token) - assertEquals(OTHER_SESSION_TOKEN, store().load(SCOPE_B)?.token) + 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(SCOPE_A, SESSION_TOKEN, expiresAtMillis = clock.now + 3_600_000) + persistSession(KEY_A, SESSION_TOKEN, expiresAtMillis = clock.now + 3_600_000) server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) assertTrue(fetch(RemoteConfigFetchRequest()) is RemoteConfigFetchResponse.Success) @@ -231,7 +234,7 @@ internal class RemoteConfigGatewayTransportTest { @Test fun `an expired persisted session is dropped and re-bootstrapped`() { - persistSession(SCOPE_A, "expired-token", expiresAtMillis = clock.now - 1) + persistSession(KEY_A, "expired-token", expiresAtMillis = clock.now - 1) server.enqueue(sessionResponse(SESSION_TOKEN)) server.enqueue(snapshotResponse(SNAPSHOT_BODY, SNAPSHOT_ETAG)) @@ -265,7 +268,7 @@ internal class RemoteConfigGatewayTransportTest { @Test fun `neither the project token nor the session token is ever logged`() { - persistSession(SCOPE_A, "stale-token") + persistSession(KEY_A, "stale-token") server.enqueue(MockResponse().setResponseCode(401)) server.enqueue(sessionResponse(SESSION_TOKEN)) server.enqueue(MockResponse().setResponseCode(401)) @@ -301,7 +304,101 @@ internal class RemoteConfigGatewayTransportTest { server.enqueue(sessionResponse(" padded-token ")) assertEquals(RemoteConfigFetchResponse.Failure(), fetch(RemoteConfigFetchRequest())) - assertNull(store().load(SCOPE_A)) + 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 @@ -317,7 +414,7 @@ internal class RemoteConfigGatewayTransportTest { assertTrue(fetch(RemoteConfigFetchRequest()) is RemoteConfigFetchResponse.Success) server.takeRequest() assertEquals(SESSION_TOKEN, server.takeRequest().getHeader(REMOTE_CONFIG_SESSION_HEADER)) - assertNull(store().load(SCOPE_A)) + assertNull(store().load(KEY_A)) } private fun fetch( @@ -334,27 +431,31 @@ internal class RemoteConfigGatewayTransportTest { return requireNotNull(received) } - private fun transport() = RemoteConfigGatewayTransport( + 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 = store(), + sessionStore = sessionStore, clock = clock, moshi = Moshi.Builder().build(), logger = logger, + maxSnapshotBodyBytes = maxSnapshotBodyBytes, ) private fun store() = PersistentRemoteConfigSessionStore(cache, Moshi.Builder().build()) private fun persistSession( - scope: RemoteConfigSnapshotScope, + key: RemoteConfigSessionKey, token: String, expiresAtMillis: Long = clock.now + 3_600_000, ) { assertTrue( store().save( - scope, + key, RemoteConfigGatewaySession( token = token, projectId = 42, @@ -373,6 +474,13 @@ internal class RemoteConfigGatewayTransportTest { "\"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) @@ -382,6 +490,22 @@ internal class RemoteConfigGatewayTransportTest { 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 } @@ -421,6 +545,7 @@ internal class RemoteConfigGatewayTransportTest { 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" @@ -428,6 +553,8 @@ internal class RemoteConfigGatewayTransportTest { 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(