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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion sdk/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ ext {
network = [
core : "com.squareup.retrofit2:retrofit:$retrofit_version",
moshiConverter : "com.squareup.retrofit2:converter-moshi:$retrofit_version",
okhttp : "com.squareup.okhttp3:okhttp:$okhttp_version"
okhttp : "com.squareup.okhttp3:okhttp:$okhttp_version",
// Retrofit 2.9.0 pulls okhttp 3.14.9, which wins conflict resolution over
// okhttp_version above; MockWebServer touches okhttp3.internal.* so it must
// match the version that actually resolves, not the one declared.
mockWebServer : "com.squareup.okhttp3:mockwebserver:3.14.9"
]

lifecycle = [
Expand Down Expand Up @@ -164,6 +168,9 @@ dependencies {
// Mockito
testImplementation 'org.mockito:mockito-core:4.3.1'

// MockWebServer (HTTP contract tests)
testImplementation network.mockWebServer

testImplementation 'androidx.test:core:1.5.0'
testImplementation 'androidx.test.ext:junit:1.1.5'
testImplementation "org.json:json:20180813"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.qonversion.android.sdk.internal.remoteconfig

import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import java.util.Locale

private const val ANDROID_PLATFORM = "android"
private const val UNKNOWN = "UNKNOWN"
private const val UNDETERMINED_LANGUAGE_TAG = "und"
private const val MILLIS_IN_SECOND = 1_000L

/**
* Builds the snapshot request's `client_context` from device facts only.
*
* The constructor takes no identity on purpose. `device_installed_at` is read from
* `PackageManager.firstInstallTime`, which is a property of the installed package on this device:
* it is untouched by `identify()`, by logout, and by the anonymous uid being re-minted. That is
* exactly the invariant the server relies on — it evaluates account age as
* `min(device_installed_at, client.created_at)`, so a post-logout client row looks brand new and
* only the preserved device install date keeps a long-standing user out of "new users" targeting.
*
* Reusing the SDK's existing install-date source (`QProductCenterManager` reads the same
* `firstInstallTime` for `install_date`) keeps a single notion of "when this device installed the
* app" across the wire.
*/
internal class DeviceRemoteConfigClientContextProvider(
private val context: Context,
private val sdkVersion: String,
) : RemoteConfigClientContextProvider {

override fun clientContext(): RemoteConfigClientContext? {
val packageInfo = packageInfo() ?: return null
return RemoteConfigClientContext(
platform = ANDROID_PLATFORM,
appVersion = packageInfo.versionName ?: UNKNOWN,
osVersion = Build.VERSION.RELEASE ?: UNKNOWN,
sdkVersion = sdkVersion,
locale = locale(),
deviceModel = Build.MODEL ?: UNKNOWN,
deviceInstalledAtSeconds = packageInfo.firstInstallTime
.coerceAtLeast(0) / MILLIS_IN_SECOND,
).takeIf { it.isValid() }
}

private fun packageInfo() = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.getPackageInfo(
context.packageName,
PackageManager.PackageInfoFlags.of(0L),
)
} else {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0)
}
} catch (_: Exception) {
null
}

/**
* `Locale.getLanguage()` still returns the pre-1989 ISO-639 codes (`iw`, `in`, `ji` instead of
* `he`, `id`, `yi`), which would silently miss those users in locale targeting.
* `toLanguageTag()` gives the modern BCP-47 subtags; the separator is normalised to `_` to
* match the shape the gateway contract documents (`en_US`).
*/
private fun locale(): String {
val tag = try {
Locale.getDefault().toLanguageTag()
} catch (_: Exception) {
""
}
return if (tag.isEmpty() || tag == UNDETERMINED_LANGUAGE_TAG) UNKNOWN else tag.replace('-', '_')
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package com.qonversion.android.sdk.internal.remoteconfig

import com.qonversion.android.sdk.internal.storage.Cache
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import java.nio.ByteBuffer
import java.security.MessageDigest

private const val REMOTE_CONFIG_SESSION_PREFIX = "qonversion_remote_config_v2_session_"
private const val REMOTE_CONFIG_SESSION_VERSION = 1
private const val REMOTE_CONFIG_SESSION_MAX_BYTES = 4 * 1024
private const val REMOTE_CONFIG_SESSION_TOKEN_MAX_BYTES = 2 * 1024

/**
* A Remote Config v2 gateway session obtained from the bootstrap route.
*
* The session token authorises snapshot reads for exactly one identity scope. It is
* intentionally *not* a device-wide credential: it is stored and looked up per
* [RemoteConfigSnapshotScope], so an identity switch can never reuse the previous
* identity's token.
*/
internal data class RemoteConfigGatewaySession(
val token: String,
val projectId: Long,
val environment: String,
val expiresAtMillis: Long,
) {
fun isUsableAt(nowMillis: Long): Boolean =
token.isNotEmpty() && projectId > 0 && environment.isNotEmpty() && nowMillis < expiresAtMillis
}

/**
* Addresses one stored session.
*
* The snapshot [scope] alone is not enough: the gateway mints a session for a specific anonymous
* [userUid], and the canonical user id of the scope is a separate notion that can in principle
* stay put while the anonymous uid is re-minted. Keying on both means a session can only ever be
* replayed for the exact identity it was issued to.
*/
internal data class RemoteConfigSessionKey(
val scope: RemoteConfigSnapshotScope,
val userUid: String,
)

internal interface RemoteConfigSessionStore {
fun load(key: RemoteConfigSessionKey): RemoteConfigGatewaySession?
fun save(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession): Boolean
fun clear(key: RemoteConfigSessionKey): Boolean
}

/**
* Durable, per-identity-scope session storage.
*
* Mirrors [PersistentRemoteConfigFetchPolicyStore]: the storage key is a salted digest of the
* scope, so neither the project key nor the canonical user id ever lands in a preference name,
* and a scope change simply addresses a different record.
*/
internal class PersistentRemoteConfigSessionStore(
private val cache: Cache,
moshi: Moshi,
) : RemoteConfigSessionStore {
private val adapter = moshi.adapter(PersistedRemoteConfigGatewaySession::class.java)

@Synchronized
@Suppress("ReturnCount")
override fun load(key: RemoteConfigSessionKey): RemoteConfigGatewaySession? {
val storageKey = remoteConfigSessionStorageKey(key)
val raw = try {
cache.getString(storageKey, null)
} catch (_: Exception) {
null
} ?: return null
val persisted = try {
raw.takeIf { it.toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_SESSION_MAX_BYTES }
?.let(adapter::fromJson)
} catch (_: Exception) {
null
}
if (persisted == null || !persisted.isValid()) {
removeInvalid(storageKey)
return null
}
return RemoteConfigGatewaySession(
token = persisted.token,
projectId = persisted.projectId,
environment = persisted.environment,
expiresAtMillis = persisted.expiresAtMillis,
)
}

@Synchronized
@Suppress("ReturnCount")
override fun save(key: RemoteConfigSessionKey, session: RemoteConfigGatewaySession): Boolean {
val persisted = PersistedRemoteConfigGatewaySession(
version = REMOTE_CONFIG_SESSION_VERSION,
token = session.token,
projectId = session.projectId,
environment = session.environment,
expiresAtMillis = session.expiresAtMillis,
)
if (!persisted.isValid()) return false
val raw = try {
adapter.toJson(persisted)
} catch (_: Exception) {
return false
}
if (raw.toByteArray(Charsets.UTF_8).size > REMOTE_CONFIG_SESSION_MAX_BYTES) return false
return try {
cache.updateStringsDurably(
values = mapOf(remoteConfigSessionStorageKey(key) to raw),
removedKeys = emptySet(),
)
} catch (_: Exception) {
false
}
}

@Synchronized
override fun clear(key: RemoteConfigSessionKey): Boolean = try {
cache.updateStringsDurably(emptyMap(), setOf(remoteConfigSessionStorageKey(key)))
} catch (_: Exception) {
false
}

private fun PersistedRemoteConfigGatewaySession.isValid(): Boolean =
version == REMOTE_CONFIG_SESSION_VERSION &&
token.isNotEmpty() &&
token.toByteArray(Charsets.UTF_8).size <= REMOTE_CONFIG_SESSION_TOKEN_MAX_BYTES &&
projectId > 0 &&
environment.isNotEmpty() &&
expiresAtMillis > 0

private fun removeInvalid(key: String) {
try {
cache.updateStringsDurably(emptyMap(), setOf(key))
} catch (_: Exception) {
// A malformed session stays untrusted even when best-effort cleanup fails.
}
}
}

@JsonClass(generateAdapter = true)
internal data class PersistedRemoteConfigGatewaySession(
val version: Int,
@Json(name = "session_token")
val token: String,
@Json(name = "project_id")
val projectId: Long,
val environment: String,
@Json(name = "expires_at_millis")
val expiresAtMillis: Long,
)

private fun remoteConfigSessionStorageKey(key: RemoteConfigSessionKey): String {
val digest = MessageDigest.getInstance("SHA-256")
digest.updateLengthPrefixed("remote-config-gateway-session-v1".encodeToByteArray())
digest.updateLengthPrefixed(key.scope.projectKey.encodeToByteArray())
digest.updateLengthPrefixed(key.scope.environment.encodeToByteArray())
digest.updateLengthPrefixed(key.scope.canonicalUserId.encodeToByteArray())
digest.updateLengthPrefixed(key.userUid.encodeToByteArray())
return REMOTE_CONFIG_SESSION_PREFIX + digest.digest().joinToString("") { byte -> "%02x".format(byte) }
}

private fun MessageDigest.updateLengthPrefixed(value: ByteArray) {
update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(value.size).array())
update(value)
}
Loading
Loading