feat: initial public release
ConsentOS — a privacy-first cookie consent management platform. Self-hosted, source-available alternative to OneTrust, Cookiebot, and CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant architecture with role-based access, configuration cascade (system → org → group → site → region), dark-pattern detection in the scanner, and a tamper-evident consent record audit trail. This is the initial public release. Prior development history is retained internally. See README.md for the feature list, architecture overview, and quick-start instructions. Licensed under the Elastic Licence 2.0 — self-host freely; do not resell as a managed service.
This commit is contained in:
84
sdks/android/consentos/build.gradle.kts
Normal file
84
sdks/android/consentos/build.gradle.kts
Normal file
@@ -0,0 +1,84 @@
|
||||
plugins {
|
||||
id("com.android.library") version "8.3.0"
|
||||
id("org.jetbrains.kotlin.android") version "1.9.23"
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.23"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.consentos"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26 // Android 8.0 (Oreo) — required for EncryptedSharedPreferences
|
||||
targetSdk = 34
|
||||
|
||||
// Consumer ProGuard rules are included in the AAR and applied to the consuming app.
|
||||
consumerProguardFiles("proguard-rules.pro")
|
||||
|
||||
// SDK version available at runtime for User-Agent and logging.
|
||||
buildConfigField("String", "SDK_VERSION", "\"${project.property("VERSION_NAME")}\"")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false // Library modules are not minified; the consuming app handles this.
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
freeCompilerArgs += listOf(
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
)
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Kotlin coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0")
|
||||
|
||||
// Kotlinx serialization (JSON)
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
|
||||
|
||||
// AndroidX — EncryptedSharedPreferences requires security-crypto
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// AndroidX annotations (@VisibleForTesting, @ColorInt, etc.)
|
||||
implementation("androidx.annotation:annotation:1.7.1")
|
||||
|
||||
// Material Components — for BottomSheetDialogFragment and theming
|
||||
implementation("com.google.android.material:material:1.11.0")
|
||||
|
||||
// AndroidX Fragment (BottomSheetDialogFragment)
|
||||
implementation("androidx.fragment:fragment-ktx:1.6.2")
|
||||
|
||||
// AppCompat — for ConsentBannerActivity base class and translucent theme
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
|
||||
// Optional: Firebase / GCM bridge (compileOnly so apps without Firebase still compile)
|
||||
compileOnly("com.google.firebase:firebase-analytics-ktx:21.6.1")
|
||||
|
||||
// Unit test dependencies — plain JVM, no instrumentation required
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.mockito:mockito-core:5.11.0")
|
||||
testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
|
||||
testImplementation("org.robolectric:robolectric:4.12.1")
|
||||
testImplementation("androidx.test:core:1.5.0")
|
||||
}
|
||||
14
sdks/android/consentos/gradle.properties
Normal file
14
sdks/android/consentos/gradle.properties
Normal file
@@ -0,0 +1,14 @@
|
||||
# Gradle properties for the CMP Consent Android SDK.
|
||||
|
||||
# SDK version published to Maven
|
||||
VERSION_NAME=1.0.0
|
||||
GROUP_ID=com.consentos
|
||||
ARTIFACT_ID=consentos
|
||||
|
||||
# Android build properties
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=false
|
||||
kotlin.code.style=official
|
||||
|
||||
# Increase heap for builds with many source files
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
98
sdks/android/consentos/proguard-rules.pro
vendored
Normal file
98
sdks/android/consentos/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
# ProGuard / R8 consumer rules for the CMP Consent Android SDK.
|
||||
#
|
||||
# These rules are included in the AAR and applied automatically to the consuming
|
||||
# app's build. They prevent R8 from stripping or renaming classes that must be
|
||||
# preserved for correct SDK operation.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CMP Consent SDK — keep all public API classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Main SDK entry point and public interfaces
|
||||
-keep class com.consentos.ConsentOS { *; }
|
||||
-keep interface com.consentos.ConsentOSListener { *; }
|
||||
|
||||
# Data models serialised to/from JSON via kotlinx.serialization
|
||||
-keep class com.consentos.ConsentState { *; }
|
||||
-keep class com.consentos.ConsentConfig { *; }
|
||||
-keep class com.consentos.ConsentConfig$** { *; }
|
||||
-keep class com.consentos.CachedConfig { *; }
|
||||
-keep class com.consentos.ConsentPayload { *; }
|
||||
|
||||
# Enums used in public API (R8 may inline or remove them if not kept explicitly)
|
||||
-keep enum com.consentos.ConsentCategory { *; }
|
||||
-keep enum com.consentos.GCMConsentType { *; }
|
||||
-keep enum com.consentos.GCMConsentStatus { *; }
|
||||
|
||||
# Storage and API protocols — allow apps to provide custom implementations
|
||||
-keep interface com.consentos.ConsentStorageProtocol { *; }
|
||||
-keep interface com.consentos.ConsentAPIProtocol { *; }
|
||||
-keep interface com.consentos.GCMAnalyticsProvider { *; }
|
||||
-keep class com.consentos.ConsentStorage { *; }
|
||||
-keep class com.consentos.ConsentAPI { *; }
|
||||
-keep class com.consentos.GCMBridge { *; }
|
||||
-keep class com.consentos.NoOpGCMAnalyticsProvider { *; }
|
||||
-keep class com.consentos.TCFStringEncoder { *; }
|
||||
|
||||
# Exceptions — keep for diagnostics / catch blocks in consuming apps
|
||||
-keep class com.consentos.ConsentAPIException { *; }
|
||||
-keep class com.consentos.ConsentAPIException$** { *; }
|
||||
|
||||
# UI components registered in the manifest
|
||||
-keep class com.consentos.ui.ConsentBannerActivity { *; }
|
||||
-keep class com.consentos.ui.ConsentBottomSheet { *; }
|
||||
-keep class com.consentos.ui.BannerTheme { *; }
|
||||
|
||||
# Broadcast action constant
|
||||
-keep class com.consentos.ConsentOSKt { *; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# kotlinx.serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Serialisation uses reflection to access serialisers; prevent R8 removing them.
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.AnnotationsKt
|
||||
|
||||
-keepclassmembers class kotlinx.serialization.json.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class kotlinx.serialization.json.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# Keep all classes annotated with @Serializable
|
||||
-keep @kotlinx.serialization.Serializable class * { *; }
|
||||
|
||||
# Serialiser lookup table generated by the Kotlin compiler
|
||||
-keep class **$$serializer { *; }
|
||||
-keepclassmembers class ** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class ** {
|
||||
public static ** INSTANCE;
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kotlin coroutines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
|
||||
-keepclassmembernames class kotlinx.** {
|
||||
volatile <fields>;
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AndroidX Security (EncryptedSharedPreferences)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
-keep class androidx.security.crypto.** { *; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Suppress common noise warnings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
-dontwarn java.lang.instrument.ClassFileTransformer
|
||||
-dontwarn sun.misc.SignalHandler
|
||||
2
sdks/android/consentos/settings.gradle.kts
Normal file
2
sdks/android/consentos/settings.gradle.kts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Settings for the CMP Consent Android SDK module.
|
||||
rootProject.name = "consentos"
|
||||
16
sdks/android/consentos/src/main/AndroidManifest.xml
Normal file
16
sdks/android/consentos/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Required for the SDK to fetch site configuration and post consent records. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application>
|
||||
<!-- ConsentBannerActivity is declared here so host apps need not add it manually. -->
|
||||
<activity
|
||||
android:name=".ui.ConsentBannerActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.AppCompat.Translucent"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.consentos
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
// =============================================================================
|
||||
// Protocol (Interface)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Abstracts the network layer for testability.
|
||||
*
|
||||
* Implementations must be safe to call from any coroutine context.
|
||||
*/
|
||||
interface ConsentAPIProtocol {
|
||||
/**
|
||||
* Fetches the effective site configuration from the API.
|
||||
*
|
||||
* @param siteId The site identifier registered in the CMP dashboard.
|
||||
* @throws ConsentAPIException on network or server errors.
|
||||
*/
|
||||
suspend fun fetchConfig(siteId: String): ConsentConfig
|
||||
|
||||
/**
|
||||
* Posts a consent record to the server.
|
||||
*
|
||||
* @param payload The consent event payload.
|
||||
* @throws ConsentAPIException on network or server errors.
|
||||
*/
|
||||
suspend fun postConsent(payload: ConsentPayload)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Errors
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Errors that can be thrown by the CMP API client.
|
||||
*/
|
||||
sealed class ConsentAPIException(message: String, cause: Throwable? = null) :
|
||||
Exception(message, cause) {
|
||||
|
||||
/** The constructed API URL is malformed. */
|
||||
class InvalidURL(url: String) :
|
||||
ConsentAPIException("The constructed API URL is invalid: $url")
|
||||
|
||||
/** The server returned a non-2xx HTTP status code. */
|
||||
class UnexpectedStatusCode(val code: Int) :
|
||||
ConsentAPIException("The server returned an unexpected HTTP status code: $code.")
|
||||
|
||||
/** The server response could not be decoded. */
|
||||
class DecodingFailure(cause: Throwable) :
|
||||
ConsentAPIException("Failed to decode the server response: ${cause.message}", cause)
|
||||
|
||||
/** A network I/O error occurred (e.g. no connectivity). */
|
||||
class NetworkFailure(cause: Throwable) :
|
||||
ConsentAPIException("A network error occurred: ${cause.message}", cause)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Consent Payload
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* The request body sent when recording a consent event.
|
||||
*/
|
||||
@Serializable
|
||||
data class ConsentPayload(
|
||||
@SerialName("site_id") val siteId: String,
|
||||
@SerialName("visitor_id") val visitorId: String,
|
||||
val platform: String = "android",
|
||||
val accepted: List<String>,
|
||||
val rejected: List<String>,
|
||||
@SerialName("consented_at") val consentedAt: String, // ISO-8601 UTC
|
||||
@SerialName("banner_version") val bannerVersion: String? = null,
|
||||
@SerialName("user_agent") val userAgent: String? = null,
|
||||
@SerialName("tc_string") val tcString: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Creates a [ConsentPayload] from a [ConsentState].
|
||||
*
|
||||
* @param siteId The site identifier.
|
||||
* @param state The consent state to serialise.
|
||||
* @param tcString Optional TC string generated by [TCFStringEncoder].
|
||||
*/
|
||||
fun from(
|
||||
siteId: String,
|
||||
state: ConsentState,
|
||||
tcString: String? = null,
|
||||
): ConsentPayload? {
|
||||
val consentedAtMs = state.consentedAtMs ?: return null
|
||||
return ConsentPayload(
|
||||
siteId = siteId,
|
||||
visitorId = state.visitorId,
|
||||
accepted = state.accepted.map { it.value },
|
||||
rejected = state.rejected.map { it.value },
|
||||
consentedAt = formatIso8601(consentedAtMs),
|
||||
bannerVersion = state.bannerVersion,
|
||||
tcString = tcString,
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatIso8601(epochMs: Long): String {
|
||||
val sdf = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US)
|
||||
sdf.timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
return sdf.format(java.util.Date(epochMs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Live Implementation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* HttpURLConnection-backed API client that communicates with the CMP API.
|
||||
*
|
||||
* Uses [kotlinx.coroutines.Dispatchers.IO] for all network calls so they are safe to
|
||||
* invoke from any coroutine context.
|
||||
*
|
||||
* @param apiBase The base URL of the CMP API (e.g. `https://api.example.com`).
|
||||
*/
|
||||
class ConsentAPI(private val apiBase: String) : ConsentAPIProtocol {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val userAgent = "ConsentOS-Android/${BuildConfig.SDK_VERSION} (Kotlin)"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ConsentAPIProtocol
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
override suspend fun fetchConfig(siteId: String): ConsentConfig =
|
||||
withContext(Dispatchers.IO) {
|
||||
val urlStr = "$apiBase/api/v1/config/sites/$siteId/effective"
|
||||
val connection = openConnection(urlStr)
|
||||
try {
|
||||
connection.requestMethod = "GET"
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", userAgent)
|
||||
connection.connect()
|
||||
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) throw ConsentAPIException.UnexpectedStatusCode(code)
|
||||
|
||||
val body = connection.inputStream.bufferedReader().readText()
|
||||
runCatching { json.decodeFromString<ConsentConfig>(body) }
|
||||
.getOrElse { throw ConsentAPIException.DecodingFailure(it) }
|
||||
} catch (e: ConsentAPIException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
throw ConsentAPIException.NetworkFailure(e)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun postConsent(payload: ConsentPayload): Unit =
|
||||
withContext(Dispatchers.IO) {
|
||||
val urlStr = "$apiBase/api/v1/consent"
|
||||
val connection = openConnection(urlStr)
|
||||
try {
|
||||
connection.requestMethod = "POST"
|
||||
connection.setRequestProperty("Content-Type", "application/json")
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", userAgent)
|
||||
connection.doOutput = true
|
||||
|
||||
val body = json.encodeToString(payload)
|
||||
connection.outputStream.bufferedWriter().use { it.write(body) }
|
||||
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) throw ConsentAPIException.UnexpectedStatusCode(code)
|
||||
} catch (e: ConsentAPIException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
throw ConsentAPIException.NetworkFailure(e)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Throws(ConsentAPIException.InvalidURL::class)
|
||||
private fun openConnection(urlStr: String): HttpURLConnection {
|
||||
return runCatching { URL(urlStr).openConnection() as HttpURLConnection }
|
||||
.getOrElse { throw ConsentAPIException.InvalidURL(urlStr) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.consentos
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
|
||||
/**
|
||||
* Consent categories matching the CMP platform's taxonomy.
|
||||
*
|
||||
* Each category maps to IAB TCF v2.2 purposes and Google Consent Mode v2 consent types.
|
||||
* The [NECESSARY] category is always granted and cannot be revoked by the user.
|
||||
*
|
||||
* Serialised using the [value] string (e.g. `"analytics"`) rather than the enum name,
|
||||
* to remain consistent with the web platform's cookie structure.
|
||||
*/
|
||||
@Serializable(with = ConsentCategorySerializer::class)
|
||||
enum class ConsentCategory(
|
||||
/** The raw string value used in API payloads and persistent storage. */
|
||||
val value: String,
|
||||
) {
|
||||
/** Strictly necessary cookies — always allowed, no consent required. */
|
||||
NECESSARY("necessary"),
|
||||
|
||||
/** Functional / preference cookies (e.g. language, saved settings). */
|
||||
FUNCTIONAL("functional"),
|
||||
|
||||
/** Analytics / statistics cookies (e.g. page views, session data). */
|
||||
ANALYTICS("analytics"),
|
||||
|
||||
/** Marketing / advertising cookies (e.g. retargeting, personalised ads). */
|
||||
MARKETING("marketing"),
|
||||
|
||||
/** Personalisation cookies (e.g. content recommendations). */
|
||||
PERSONALISATION("personalisation");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TCF Purpose Mappings
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* IAB TCF v2.2 purpose IDs associated with this category.
|
||||
*
|
||||
* Returns an empty list for [NECESSARY], which does not require consent purposes.
|
||||
*/
|
||||
val tcfPurposeIds: List<Int>
|
||||
get() = when (this) {
|
||||
NECESSARY -> emptyList()
|
||||
FUNCTIONAL -> listOf(1) // Store and/or access information on a device
|
||||
ANALYTICS -> listOf(7, 8, 9, 10) // Measurement, market research, product development
|
||||
MARKETING -> listOf(2, 3, 4) // Select basic/personalised ads, create ad profile
|
||||
PERSONALISATION -> listOf(5, 6) // Create content profile, select personalised content
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Google Consent Mode v2 Mappings
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Google Consent Mode v2 consent type string for this category.
|
||||
*
|
||||
* Returns `null` for categories that do not have a direct GCM mapping.
|
||||
*/
|
||||
val gcmConsentType: String?
|
||||
get() = when (this) {
|
||||
NECESSARY -> null
|
||||
FUNCTIONAL -> "functionality_storage"
|
||||
ANALYTICS -> "analytics_storage"
|
||||
MARKETING -> "ad_storage"
|
||||
PERSONALISATION -> "personalization_storage"
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Display
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Human-readable display name for the category (British English). */
|
||||
val displayName: String
|
||||
get() = when (this) {
|
||||
NECESSARY -> "Strictly Necessary"
|
||||
FUNCTIONAL -> "Functional"
|
||||
ANALYTICS -> "Analytics"
|
||||
MARKETING -> "Marketing"
|
||||
PERSONALISATION -> "Personalisation"
|
||||
}
|
||||
|
||||
/** Brief description of the category for banner display. */
|
||||
val displayDescription: String
|
||||
get() = when (this) {
|
||||
NECESSARY -> "Essential for the application to function. These cannot be disabled."
|
||||
FUNCTIONAL -> "Enable enhanced functionality such as remembering your preferences."
|
||||
ANALYTICS -> "Help us understand how users interact with the application."
|
||||
MARKETING -> "Used to deliver relevant advertisements and track ad campaign performance."
|
||||
PERSONALISATION -> "Allow us to personalise content based on your interests."
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether consent for this category is required before storing data.
|
||||
*
|
||||
* [NECESSARY] is exempt from consent requirements under ePrivacy regulations.
|
||||
*/
|
||||
val requiresConsent: Boolean
|
||||
get() = this != NECESSARY
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Returns the [ConsentCategory] matching [value], or `null` if unrecognised.
|
||||
*/
|
||||
fun fromValue(value: String): ConsentCategory? =
|
||||
entries.firstOrNull { it.value == value }
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Kotlinx Serializer
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Custom kotlinx.serialization serialiser for [ConsentCategory].
|
||||
*
|
||||
* Serialises using the [ConsentCategory.value] string (e.g. `"analytics"`) so that
|
||||
* JSON payloads are consistent with the web platform's cookie structure.
|
||||
*/
|
||||
object ConsentCategorySerializer : KSerializer<ConsentCategory> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("ConsentCategory", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: ConsentCategory) {
|
||||
encoder.encodeString(value.value)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): ConsentCategory {
|
||||
val raw = decoder.decodeString()
|
||||
return ConsentCategory.fromValue(raw)
|
||||
?: throw kotlinx.serialization.SerializationException(
|
||||
"Unknown ConsentCategory value: $raw",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.consentos
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The effective site configuration loaded from the CMP API.
|
||||
*
|
||||
* Maps to the response of `GET {apiBase}/api/v1/config/sites/{siteId}/effective`.
|
||||
* The configuration drives banner display, theming, blocking mode, and consent expiry.
|
||||
*/
|
||||
@Serializable
|
||||
data class ConsentConfig(
|
||||
/** Unique identifier for the site. */
|
||||
@SerialName("site_id")
|
||||
val siteId: String,
|
||||
|
||||
/** Human-readable name for the site. */
|
||||
@SerialName("site_name")
|
||||
val siteName: String,
|
||||
|
||||
/** The blocking mode determining the default consent model. */
|
||||
@SerialName("blocking_mode")
|
||||
val blockingMode: BlockingMode,
|
||||
|
||||
/** Consent validity in days. After expiry the banner is shown again. */
|
||||
@SerialName("consent_expiry_days")
|
||||
val consentExpiryDays: Int,
|
||||
|
||||
/**
|
||||
* The current version of this configuration.
|
||||
* Stored alongside consent records to detect when re-consent is needed.
|
||||
*/
|
||||
@SerialName("banner_version")
|
||||
val bannerVersion: String,
|
||||
|
||||
/** Banner display and theming configuration. */
|
||||
@SerialName("banner_config")
|
||||
val bannerConfig: BannerConfig,
|
||||
|
||||
/** Available categories for this site. */
|
||||
val categories: List<CategoryConfig> = emptyList(),
|
||||
) {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Blocking Mode
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The consent model applied to visitors.
|
||||
*/
|
||||
@Serializable
|
||||
enum class BlockingMode(val value: String) {
|
||||
/** User must opt in before non-essential scripts run (GDPR default). */
|
||||
@SerialName("opt_in")
|
||||
OPT_IN("opt_in"),
|
||||
|
||||
/** Non-essential scripts run by default; user may opt out (CCPA default). */
|
||||
@SerialName("opt_out")
|
||||
OPT_OUT("opt_out"),
|
||||
|
||||
/** Informational notice only; no blocking. */
|
||||
@SerialName("informational")
|
||||
INFORMATIONAL("informational"),
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Banner Configuration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Visual and behavioural configuration for the consent banner.
|
||||
*/
|
||||
@Serializable
|
||||
data class BannerConfig(
|
||||
/** Display mode controlling the banner layout. */
|
||||
@SerialName("display_mode")
|
||||
val displayMode: DisplayMode = DisplayMode.BOTTOM_BANNER,
|
||||
|
||||
/** Primary background colour as a hex string (e.g. `"#FFFFFF"`). */
|
||||
@SerialName("background_color")
|
||||
val backgroundColor: String? = null,
|
||||
|
||||
/** Primary text colour as a hex string. */
|
||||
@SerialName("text_color")
|
||||
val textColor: String? = null,
|
||||
|
||||
/** Accent colour used for buttons and highlights. */
|
||||
@SerialName("accent_color")
|
||||
val accentColor: String? = null,
|
||||
|
||||
/** Text for the "Accept all" button. */
|
||||
@SerialName("accept_button_text")
|
||||
val acceptButtonText: String? = null,
|
||||
|
||||
/** Text for the "Reject all" button. */
|
||||
@SerialName("reject_button_text")
|
||||
val rejectButtonText: String? = null,
|
||||
|
||||
/** Text for the "Manage preferences" button. */
|
||||
@SerialName("manage_button_text")
|
||||
val manageButtonText: String? = null,
|
||||
|
||||
/** The banner title text. */
|
||||
val title: String? = null,
|
||||
|
||||
/** The banner body copy. */
|
||||
val description: String? = null,
|
||||
|
||||
/** URL for the site's privacy policy. */
|
||||
@SerialName("privacy_policy_url")
|
||||
val privacyPolicyUrl: String? = null,
|
||||
) {
|
||||
/** Display mode options for the banner. */
|
||||
@Serializable
|
||||
enum class DisplayMode(val value: String) {
|
||||
@SerialName("overlay")
|
||||
OVERLAY("overlay"),
|
||||
|
||||
@SerialName("bottom_banner")
|
||||
BOTTOM_BANNER("bottom_banner"),
|
||||
|
||||
@SerialName("top_banner")
|
||||
TOP_BANNER("top_banner"),
|
||||
|
||||
@SerialName("corner_popup")
|
||||
CORNER_POPUP("corner_popup"),
|
||||
|
||||
@SerialName("inline")
|
||||
INLINE("inline"),
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Category Configuration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-category configuration as returned by the API.
|
||||
*/
|
||||
@Serializable
|
||||
data class CategoryConfig(
|
||||
/** Machine-readable category key (matches [ConsentCategory.value]). */
|
||||
val key: String,
|
||||
|
||||
/** Whether this category is enabled for this site. */
|
||||
val enabled: Boolean,
|
||||
|
||||
/** Overridden display name (falls back to [ConsentCategory.displayName] if absent). */
|
||||
@SerialName("display_name")
|
||||
val displayName: String? = null,
|
||||
|
||||
/** Overridden description text. */
|
||||
val description: String? = null,
|
||||
) {
|
||||
/** Resolves to the matching [ConsentCategory], if the key is recognised. */
|
||||
val category: ConsentCategory?
|
||||
get() = ConsentCategory.fromValue(key)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Convenience
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Returns only the enabled [ConsentCategory] values for this config. */
|
||||
val enabledCategories: List<ConsentCategory>
|
||||
get() = categories.mapNotNull { cfg ->
|
||||
if (cfg.enabled) cfg.category else null
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cached Config Wrapper
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wraps a [ConsentConfig] with metadata needed for cache invalidation.
|
||||
*/
|
||||
@Serializable
|
||||
data class CachedConfig(
|
||||
val config: ConsentConfig,
|
||||
/** Epoch milliseconds at which the config was fetched. */
|
||||
val fetchedAtMs: Long,
|
||||
) {
|
||||
companion object {
|
||||
/** The cache TTL in milliseconds (10 minutes). */
|
||||
const val TTL_MS: Long = 10 * 60 * 1_000L
|
||||
}
|
||||
|
||||
/** Returns `true` if the cached entry has exceeded the TTL. */
|
||||
val isExpired: Boolean
|
||||
get() = System.currentTimeMillis() - fetchedAtMs > TTL_MS
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package com.consentos
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import com.consentos.ui.ConsentBottomSheet
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// =============================================================================
|
||||
// Listener Interface
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Receives notifications when the user's consent choices change.
|
||||
*
|
||||
* Methods are invoked on the main thread.
|
||||
*/
|
||||
interface ConsentOSListener {
|
||||
/**
|
||||
* Called after consent has been updated.
|
||||
*
|
||||
* @param state The new consent state.
|
||||
*/
|
||||
fun onConsentChanged(state: ConsentState)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Broadcast Actions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Intent action broadcast when consent state changes.
|
||||
*
|
||||
* The updated [ConsentState] is not included in the broadcast — call
|
||||
* [ConsentOS.getConsentStatus] to read the current state.
|
||||
*/
|
||||
const val ACTION_CONSENT_CHANGED = "com.consentos.ACTION_CONSENT_CHANGED"
|
||||
|
||||
// =============================================================================
|
||||
// ConsentOS Singleton
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* The main entry point for the CMP Android SDK.
|
||||
*
|
||||
* Use the singleton [instance] to configure the SDK, display the consent banner,
|
||||
* and query consent status. All public methods are coroutine-safe.
|
||||
*
|
||||
* ```kotlin
|
||||
* // In Application.onCreate()
|
||||
* ConsentOS.instance.configure(
|
||||
* context = applicationContext,
|
||||
* siteId = "my-site-id",
|
||||
* apiBase = "https://api.example.com",
|
||||
* )
|
||||
*
|
||||
* // In your Activity / Fragment
|
||||
* lifecycleScope.launch {
|
||||
* if (ConsentOS.instance.shouldShowBanner()) {
|
||||
* ConsentOS.instance.showBanner(supportFragmentManager)
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
class ConsentOS internal constructor(
|
||||
private var storage: ConsentStorageProtocol? = null,
|
||||
private var api: ConsentAPIProtocol? = null,
|
||||
private var gcmBridge: GCMBridge = GCMBridge(),
|
||||
) {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Singleton
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
companion object {
|
||||
/** The shared SDK instance. Configure this before use. */
|
||||
@JvmField
|
||||
val instance: ConsentOS = ConsentOS()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// State
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** The site ID set during [configure]. */
|
||||
@Volatile
|
||||
var siteId: String? = null
|
||||
@androidx.annotation.VisibleForTesting internal set
|
||||
|
||||
/** Whether the SDK has been configured. */
|
||||
val isConfigured: Boolean get() = siteId != null
|
||||
|
||||
/** The current consent state. `null` until [configure] has been called. */
|
||||
@Volatile
|
||||
var consentState: ConsentState? = null
|
||||
@androidx.annotation.VisibleForTesting internal set
|
||||
|
||||
/** The currently loaded site configuration. `null` until fetched. */
|
||||
@Volatile
|
||||
var siteConfig: ConsentConfig? = null
|
||||
@androidx.annotation.VisibleForTesting internal set
|
||||
|
||||
private val listeners = mutableListOf<ConsentOSListener>()
|
||||
private val listenerLock = Any()
|
||||
|
||||
// Mutex guards mutations to consentState and siteConfig across coroutines.
|
||||
private val stateMutex = Mutex()
|
||||
|
||||
// Application context used for broadcasting intents.
|
||||
@Volatile
|
||||
private var appContext: Context? = null
|
||||
|
||||
// Background scope for config refresh and consent sync operations.
|
||||
private val sdkScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Configures the SDK with a site ID and API base URL.
|
||||
*
|
||||
* Must be called before any other method. Loads the persisted consent state
|
||||
* and fetches the site configuration in the background.
|
||||
*
|
||||
* @param context Application context (used for encrypted storage and broadcasts).
|
||||
* @param siteId The unique identifier for the site (from the CMP dashboard).
|
||||
* @param apiBase Base URL of the CMP API (e.g. `https://api.example.com`).
|
||||
* @param gcmProvider Optional GCM analytics provider. Defaults to no-op.
|
||||
*/
|
||||
fun configure(
|
||||
context: Context,
|
||||
siteId: String,
|
||||
apiBase: String,
|
||||
gcmProvider: GCMAnalyticsProvider? = null,
|
||||
) {
|
||||
this.appContext = context.applicationContext
|
||||
this.siteId = siteId
|
||||
|
||||
if (storage == null) {
|
||||
storage = ConsentStorage(context.applicationContext)
|
||||
}
|
||||
if (api == null) {
|
||||
api = ConsentAPI(apiBase)
|
||||
}
|
||||
if (gcmProvider != null) {
|
||||
gcmBridge = GCMBridge(gcmProvider)
|
||||
}
|
||||
|
||||
// Restore persisted state synchronously so it is immediately available.
|
||||
val store = storage!!
|
||||
val visitorId = store.visitorId()
|
||||
consentState = store.loadState() ?: ConsentState(visitorId = visitorId)
|
||||
|
||||
// Fetch config in the background; apply GCM defaults once available.
|
||||
sdkScope.launch { refreshConfigIfNeeded() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the API client. Useful for testing or custom transports.
|
||||
*/
|
||||
fun setAPI(api: ConsentAPIProtocol) {
|
||||
this.api = api
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Banner Display
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns `true` if the consent banner should be shown to this visitor.
|
||||
*
|
||||
* The banner is required when:
|
||||
* - The user has not yet interacted (no [ConsentState.consentedAtMs]), or
|
||||
* - The stored banner version differs from the current config version, or
|
||||
* - The stored consent is older than the site's configured expiry.
|
||||
*/
|
||||
suspend fun shouldShowBanner(): Boolean {
|
||||
refreshConfigIfNeeded()
|
||||
|
||||
val state = consentState
|
||||
val config = siteConfig
|
||||
|
||||
if (state == null || !state.hasInteracted) return true
|
||||
|
||||
if (config != null && state.consentedAtMs != null) {
|
||||
// Check banner version mismatch — re-consent required after config change.
|
||||
if (state.bannerVersion != config.bannerVersion) return true
|
||||
|
||||
// Check consent expiry.
|
||||
val expiryMs = config.consentExpiryDays * 86_400_000L
|
||||
if (System.currentTimeMillis() - state.consentedAtMs > expiryMs) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Presents the consent banner as a [ConsentBottomSheet] attached to the given
|
||||
* [FragmentManager].
|
||||
*
|
||||
* Safe to call from the main thread. The bottom sheet is shown immediately if the
|
||||
* fragment manager is in a valid state.
|
||||
*
|
||||
* @param fragmentManager The [FragmentManager] from the calling Activity or Fragment.
|
||||
* @param tag Optional back-stack tag. Defaults to `"cmp_consent_banner"`.
|
||||
*/
|
||||
fun showBanner(
|
||||
fragmentManager: FragmentManager,
|
||||
tag: String = "cmp_consent_banner",
|
||||
) {
|
||||
val config = siteConfig
|
||||
val sheet = ConsentBottomSheet.newInstance(config)
|
||||
sheet.show(fragmentManager, tag)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Consent Actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Accepts all non-necessary consent categories.
|
||||
*
|
||||
* Updates local state, syncs to the server, and fires GCM signals.
|
||||
*/
|
||||
suspend fun acceptAll() {
|
||||
applyConsent { it.acceptingAll() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects all non-necessary consent categories.
|
||||
*/
|
||||
suspend fun rejectAll() {
|
||||
applyConsent { it.rejectingAll() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts only the specified [categories] (and rejects all others).
|
||||
*
|
||||
* @param categories The set of categories to accept.
|
||||
*/
|
||||
suspend fun acceptCategories(categories: List<ConsentCategory>) {
|
||||
applyConsent { it.accepting(categories.toSet()) }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Query
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the consent status for a specific [category].
|
||||
*
|
||||
* @return `true` if consent has been granted, `false` if denied or not yet given.
|
||||
*/
|
||||
fun getConsentStatus(category: ConsentCategory): Boolean =
|
||||
consentState?.isGranted(category) ?: (category == ConsentCategory.NECESSARY)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// User Identity
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Associates the current visitor with a verified user identity.
|
||||
*
|
||||
* The JWT is sent alongside subsequent consent records for server-side correlation
|
||||
* with authenticated users.
|
||||
*
|
||||
* @param jwt A signed JWT issued by the host application's auth system.
|
||||
*/
|
||||
fun identifyUser(jwt: String) {
|
||||
// Store JWT in plaintext preferences for inclusion in future consent payloads.
|
||||
// In a production implementation this would also trigger a re-sync of the consent record.
|
||||
appContext?.getSharedPreferences("com_cmp_consent_identity", Context.MODE_PRIVATE)
|
||||
?.edit()
|
||||
?.putString("user_jwt", jwt)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Listener Registration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Registers a [ConsentOSListener] to be notified of consent changes.
|
||||
*
|
||||
* Listeners are held strongly; call [removeConsentListener] to avoid leaks.
|
||||
*/
|
||||
fun addConsentListener(listener: ConsentOSListener) {
|
||||
synchronized(listenerLock) { listeners.add(listener) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a previously registered [ConsentOSListener].
|
||||
*/
|
||||
fun removeConsentListener(listener: ConsentOSListener) {
|
||||
synchronized(listenerLock) { listeners.remove(listener) }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal: Config Refresh
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
internal suspend fun refreshConfigIfNeeded(): ConsentConfig? {
|
||||
// Return cached config if it is still valid.
|
||||
storage?.loadCachedConfig()?.takeUnless { it.isExpired }?.let { cached ->
|
||||
stateMutex.withLock { siteConfig = cached.config }
|
||||
return cached.config
|
||||
}
|
||||
|
||||
val currentSiteId = siteId ?: return null
|
||||
val currentApi = api ?: return null
|
||||
|
||||
return runCatching {
|
||||
val config = currentApi.fetchConfig(currentSiteId)
|
||||
val cached = CachedConfig(config = config, fetchedAtMs = System.currentTimeMillis())
|
||||
storage?.saveCachedConfig(cached)
|
||||
|
||||
stateMutex.withLock {
|
||||
siteConfig = config
|
||||
// Stamp the banner version onto the current state.
|
||||
consentState = consentState?.copy(bannerVersion = config.bannerVersion)
|
||||
}
|
||||
|
||||
// Apply GCM defaults for new visitors.
|
||||
gcmBridge.applyDefaults(config)
|
||||
config
|
||||
}.getOrNull()
|
||||
// Non-fatal — the SDK can operate with cached or default config.
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal: Apply Consent
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private suspend fun applyConsent(transform: (ConsentState) -> ConsentState) {
|
||||
val newState = stateMutex.withLock {
|
||||
val current = consentState ?: return
|
||||
val transformed = transform(current)
|
||||
// Stamp the current banner version.
|
||||
val stamped = transformed.copy(
|
||||
bannerVersion = siteConfig?.bannerVersion ?: current.bannerVersion,
|
||||
)
|
||||
consentState = stamped
|
||||
stamped
|
||||
}
|
||||
|
||||
// Persist locally.
|
||||
storage?.saveState(newState)
|
||||
|
||||
// Signal GCM.
|
||||
gcmBridge.applyConsent(newState)
|
||||
|
||||
// Generate TC string.
|
||||
val tcString = siteConfig?.let { TCFStringEncoder.encode(newState, it) }
|
||||
|
||||
// Sync to server (best-effort; failures are non-fatal).
|
||||
syncConsent(newState, tcString)
|
||||
|
||||
// Notify listeners and broadcast on the main thread.
|
||||
withContext(Dispatchers.Main) {
|
||||
notifyListeners(newState)
|
||||
broadcastConsentChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncConsent(state: ConsentState, tcString: String?) {
|
||||
val currentSiteId = siteId ?: return
|
||||
val currentApi = api ?: return
|
||||
|
||||
val payload = ConsentPayload.from(
|
||||
siteId = currentSiteId,
|
||||
state = state,
|
||||
tcString = tcString,
|
||||
) ?: return
|
||||
|
||||
runCatching { currentApi.postConsent(payload) }
|
||||
// Consent is stored locally; server sync failure is non-fatal.
|
||||
// In production, implement a retry queue backed by WorkManager.
|
||||
}
|
||||
|
||||
private fun notifyListeners(state: ConsentState) {
|
||||
val snapshot = synchronized(listenerLock) { listeners.toList() }
|
||||
snapshot.forEach { it.onConsentChanged(state) }
|
||||
}
|
||||
|
||||
private fun broadcastConsentChanged() {
|
||||
appContext?.sendBroadcast(Intent(ACTION_CONSENT_CHANGED))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.consentos
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents the complete consent state for a visitor.
|
||||
*
|
||||
* This model mirrors the web consent cookie structure for cross-platform consistency.
|
||||
* It is persisted locally via [ConsentStorage] and synced to the server.
|
||||
*/
|
||||
@Serializable
|
||||
data class ConsentState(
|
||||
/**
|
||||
* A stable, anonymous identifier for this device/visitor.
|
||||
* Generated once and persisted across sessions.
|
||||
*/
|
||||
val visitorId: String,
|
||||
|
||||
/** The set of categories the visitor has explicitly accepted. */
|
||||
val accepted: Set<ConsentCategory> = emptySet(),
|
||||
|
||||
/** The set of categories the visitor has explicitly rejected. */
|
||||
val rejected: Set<ConsentCategory> = emptySet(),
|
||||
|
||||
/**
|
||||
* The timestamp (epoch milliseconds) at which consent was last recorded.
|
||||
* `null` until the user interacts with the banner.
|
||||
*/
|
||||
val consentedAtMs: Long? = null,
|
||||
|
||||
/**
|
||||
* The banner configuration version active when consent was collected.
|
||||
* Used to detect when consent must be re-collected after a config change.
|
||||
*/
|
||||
val bannerVersion: String? = null,
|
||||
) {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Derived State
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether the user has interacted with the banner (accepted or rejected).
|
||||
*
|
||||
* Returns `false` when the state represents the pre-consent default.
|
||||
*/
|
||||
val hasInteracted: Boolean
|
||||
get() = consentedAtMs != null
|
||||
|
||||
/**
|
||||
* Returns `true` if the user has granted consent for the given [category].
|
||||
*
|
||||
* [ConsentCategory.NECESSARY] is always considered granted regardless of the stored state.
|
||||
*/
|
||||
fun isGranted(category: ConsentCategory): Boolean {
|
||||
if (category == ConsentCategory.NECESSARY) return true
|
||||
return category in accepted
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the user has explicitly denied consent for the given [category].
|
||||
*/
|
||||
fun isDenied(category: ConsentCategory): Boolean {
|
||||
if (category == ConsentCategory.NECESSARY) return false
|
||||
return category in rejected
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mutations — returns new instances (immutable pattern)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a new state with all non-necessary categories accepted.
|
||||
*/
|
||||
fun acceptingAll(): ConsentState {
|
||||
val allOptional = ConsentCategory.entries.filter { it.requiresConsent }.toSet()
|
||||
return copy(
|
||||
accepted = allOptional,
|
||||
rejected = emptySet(),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new state with all non-necessary categories rejected.
|
||||
*/
|
||||
fun rejectingAll(): ConsentState {
|
||||
val allOptional = ConsentCategory.entries.filter { it.requiresConsent }.toSet()
|
||||
return copy(
|
||||
accepted = emptySet(),
|
||||
rejected = allOptional,
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new state accepting only the specified [categories] (and rejecting all others).
|
||||
*
|
||||
* Passing [ConsentCategory.NECESSARY] in the set is a no-op — it will not appear in
|
||||
* either [accepted] or [rejected].
|
||||
*/
|
||||
fun accepting(categories: Set<ConsentCategory>): ConsentState {
|
||||
val allOptional = ConsentCategory.entries.filter { it.requiresConsent }.toSet()
|
||||
val toAccept = categories.filter { it.requiresConsent }.toSet()
|
||||
val toReject = allOptional - toAccept
|
||||
return copy(
|
||||
accepted = toAccept,
|
||||
rejected = toReject,
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.consentos
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.UUID
|
||||
|
||||
// =============================================================================
|
||||
// Protocol (Interface)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Abstracts local persistence so the storage layer can be swapped in tests.
|
||||
*/
|
||||
interface ConsentStorageProtocol {
|
||||
fun loadState(): ConsentState?
|
||||
fun saveState(state: ConsentState)
|
||||
fun clearState()
|
||||
|
||||
fun loadCachedConfig(): CachedConfig?
|
||||
fun saveCachedConfig(cached: CachedConfig)
|
||||
fun clearCachedConfig()
|
||||
|
||||
/** Loads or generates a stable visitor ID. */
|
||||
fun visitorId(): String
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EncryptedSharedPreferences Implementation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Persists consent state and site configuration using [EncryptedSharedPreferences].
|
||||
*
|
||||
* All keys are namespaced under `com.cmp.consent` to avoid collisions.
|
||||
* Uses AES-256 encryption via the AndroidKeyStore.
|
||||
*
|
||||
* On API 26+ (Android 8.0), [EncryptedSharedPreferences] is the recommended approach
|
||||
* for storing sensitive data like consent records.
|
||||
*/
|
||||
class ConsentStorage(context: Context) : ConsentStorageProtocol {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Keys
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private object Keys {
|
||||
const val CONSENT_STATE = "com.cmp.consent.state"
|
||||
const val CACHED_CONFIG = "com.cmp.consent.config"
|
||||
const val VISITOR_ID = "com.cmp.consent.visitorId"
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Dependencies
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private val prefs: SharedPreferences = buildEncryptedPrefs(context)
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ConsentState
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
override fun loadState(): ConsentState? {
|
||||
val raw = prefs.getString(Keys.CONSENT_STATE, null) ?: return null
|
||||
return runCatching { json.decodeFromString<ConsentState>(raw) }.getOrNull()
|
||||
}
|
||||
|
||||
override fun saveState(state: ConsentState) {
|
||||
val raw = runCatching { json.encodeToString(state) }.getOrNull() ?: return
|
||||
prefs.edit().putString(Keys.CONSENT_STATE, raw).apply()
|
||||
}
|
||||
|
||||
override fun clearState() {
|
||||
prefs.edit().remove(Keys.CONSENT_STATE).apply()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cached Config
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
override fun loadCachedConfig(): CachedConfig? {
|
||||
val raw = prefs.getString(Keys.CACHED_CONFIG, null) ?: return null
|
||||
return runCatching { json.decodeFromString<CachedConfig>(raw) }.getOrNull()
|
||||
}
|
||||
|
||||
override fun saveCachedConfig(cached: CachedConfig) {
|
||||
val raw = runCatching { json.encodeToString(cached) }.getOrNull() ?: return
|
||||
prefs.edit().putString(Keys.CACHED_CONFIG, raw).apply()
|
||||
}
|
||||
|
||||
override fun clearCachedConfig() {
|
||||
prefs.edit().remove(Keys.CACHED_CONFIG).apply()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Visitor ID
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the persisted visitor ID, generating and saving a new UUID if absent.
|
||||
*
|
||||
* The visitor ID is stored in a separate, plaintext SharedPreferences file so it
|
||||
* persists even if the encrypted preferences are reset.
|
||||
*/
|
||||
override fun visitorId(): String {
|
||||
val existing = prefs.getString(Keys.VISITOR_ID, null)
|
||||
if (existing != null) return existing
|
||||
val newId = UUID.randomUUID().toString()
|
||||
prefs.edit().putString(Keys.VISITOR_ID, newId).apply()
|
||||
return newId
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun buildEncryptedPrefs(context: Context): SharedPreferences {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"com_cmp_consent_prefs",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.consentos
|
||||
|
||||
/**
|
||||
* Google Consent Mode v2 consent type strings.
|
||||
*/
|
||||
enum class GCMConsentType(val value: String) {
|
||||
ANALYTICS_STORAGE("analytics_storage"),
|
||||
AD_STORAGE("ad_storage"),
|
||||
AD_USER_DATA("ad_user_data"),
|
||||
AD_PERSONALISATION("ad_personalization"),
|
||||
FUNCTIONALITY_STORAGE("functionality_storage"),
|
||||
PERSONALISATION_STORAGE("personalization_storage"),
|
||||
SECURITY_STORAGE("security_storage"),
|
||||
}
|
||||
|
||||
/**
|
||||
* The granted/denied status for a GCM consent type.
|
||||
*/
|
||||
enum class GCMConsentStatus(val value: String) {
|
||||
GRANTED("granted"),
|
||||
DENIED("denied"),
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GCM Analytics Provider Protocol
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Abstracts the Firebase Analytics / Google Tag Manager call surface.
|
||||
*
|
||||
* Implement this interface and pass an instance to [GCMBridge] to forward consent
|
||||
* signals to Firebase Analytics. A no-op implementation ([NoOpGCMAnalyticsProvider])
|
||||
* is used when Firebase is not present.
|
||||
*
|
||||
* Example with Firebase Analytics:
|
||||
* ```kotlin
|
||||
* class FirebaseGCMProvider : GCMAnalyticsProvider {
|
||||
* override fun setConsentDefaults(defaults: Map<String, String>) {
|
||||
* val consentMap = defaults.mapKeys { FirebaseAnalytics.ConsentType.valueOf(it.key.uppercase()) }
|
||||
* .mapValues { FirebaseAnalytics.ConsentStatus.valueOf(it.value.uppercase()) }
|
||||
* Firebase.analytics.setConsent(consentMap)
|
||||
* }
|
||||
*
|
||||
* override fun updateConsent(updates: Map<String, String>) {
|
||||
* val consentMap = updates.mapKeys { FirebaseAnalytics.ConsentType.valueOf(it.key.uppercase()) }
|
||||
* .mapValues { FirebaseAnalytics.ConsentStatus.valueOf(it.value.uppercase()) }
|
||||
* Firebase.analytics.setConsent(consentMap)
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
interface GCMAnalyticsProvider {
|
||||
/** Sets the default consent state before any user interaction. */
|
||||
fun setConsentDefaults(defaults: Map<String, String>)
|
||||
|
||||
/** Updates consent state after the user interacts with the banner. */
|
||||
fun updateConsent(updates: Map<String, String>)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// No-Op Provider
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* A no-op [GCMAnalyticsProvider] used when Firebase Analytics is not linked.
|
||||
*
|
||||
* This is the default provider; replace it with a Firebase implementation to
|
||||
* activate Google Consent Mode v2 signals.
|
||||
*/
|
||||
class NoOpGCMAnalyticsProvider : GCMAnalyticsProvider {
|
||||
override fun setConsentDefaults(defaults: Map<String, String>) = Unit
|
||||
override fun updateConsent(updates: Map<String, String>) = Unit
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GCM Bridge
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Maps CMP consent state to Google Consent Mode v2 signals.
|
||||
*
|
||||
* On app launch, call [applyDefaults] with the site config before the user interacts.
|
||||
* After consent is collected, call [applyConsent] to update GCM.
|
||||
*
|
||||
* @param provider The analytics provider to forward signals to. Defaults to a no-op.
|
||||
*/
|
||||
class GCMBridge(
|
||||
private val provider: GCMAnalyticsProvider = NoOpGCMAnalyticsProvider(),
|
||||
) {
|
||||
|
||||
/**
|
||||
* Sends default (pre-consent) consent signals to GCM based on the site's blocking mode.
|
||||
*
|
||||
* Call this as early as possible — ideally before any analytics events are sent.
|
||||
*
|
||||
* @param config The effective site configuration.
|
||||
*/
|
||||
fun applyDefaults(config: ConsentConfig) {
|
||||
val defaults = buildDefaults(config.blockingMode)
|
||||
provider.setConsentDefaults(defaults)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates GCM consent signals to reflect the user's explicit choices.
|
||||
*
|
||||
* @param state The resolved consent state after user interaction.
|
||||
*/
|
||||
fun applyConsent(state: ConsentState) {
|
||||
val updates = buildConsentMap(state)
|
||||
provider.updateConsent(updates)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Builds the default GCM consent map based on blocking mode.
|
||||
*
|
||||
* - `opt_in`: all types denied by default (GDPR).
|
||||
* - `opt_out`: all types granted by default (CCPA).
|
||||
* - `informational`: all types granted.
|
||||
*/
|
||||
private fun buildDefaults(mode: ConsentConfig.BlockingMode): Map<String, String> {
|
||||
val status = if (mode == ConsentConfig.BlockingMode.OPT_IN) {
|
||||
GCMConsentStatus.DENIED
|
||||
} else {
|
||||
GCMConsentStatus.GRANTED
|
||||
}
|
||||
return GCMConsentType.entries.associate { it.value to status.value }
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the consent state's accepted/rejected categories to GCM consent type values.
|
||||
*/
|
||||
private fun buildConsentMap(state: ConsentState): Map<String, String> {
|
||||
val map = mutableMapOf<String, String>()
|
||||
|
||||
// security_storage is always granted — it is required for security functionality.
|
||||
map[GCMConsentType.SECURITY_STORAGE.value] = GCMConsentStatus.GRANTED.value
|
||||
|
||||
for (category in ConsentCategory.entries) {
|
||||
val gcmType = category.gcmConsentType ?: continue
|
||||
val status = if (state.isGranted(category)) {
|
||||
GCMConsentStatus.GRANTED
|
||||
} else {
|
||||
GCMConsentStatus.DENIED
|
||||
}
|
||||
map[gcmType] = status.value
|
||||
}
|
||||
|
||||
// ad_user_data and ad_personalization follow the marketing category.
|
||||
val marketingGranted = state.isGranted(ConsentCategory.MARKETING)
|
||||
val marketingStatus = if (marketingGranted) GCMConsentStatus.GRANTED else GCMConsentStatus.DENIED
|
||||
map[GCMConsentType.AD_USER_DATA.value] = marketingStatus.value
|
||||
map[GCMConsentType.AD_PERSONALISATION.value] = marketingStatus.value
|
||||
|
||||
return map
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.consentos
|
||||
|
||||
import android.util.Base64
|
||||
|
||||
/**
|
||||
* Encodes a TC string (Transparency & Consent Framework v2.2) from consent state.
|
||||
*
|
||||
* The TC string is a Base64url-encoded bit field described in the IAB TCF v2.2 specification.
|
||||
* This implementation encodes the core consent section (segment type 0), which is sufficient
|
||||
* for signalling purpose consent to downstream vendors.
|
||||
*
|
||||
* Reference:
|
||||
* https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework
|
||||
*/
|
||||
object TCFStringEncoder {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Constants
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** TCF specification version. */
|
||||
private const val SPEC_VERSION = 2
|
||||
|
||||
/** A fixed CMP ID — replace with your registered IAB CMP ID in production. */
|
||||
private const val CMP_ID = 0
|
||||
|
||||
/** CMP SDK version number. */
|
||||
private const val CMP_VERSION = 1
|
||||
|
||||
/** IAB consent language (EN). */
|
||||
private const val CONSENT_LANGUAGE = "EN"
|
||||
|
||||
/** Vendor list version. In production, fetch from the GVL. */
|
||||
private const val VENDOR_LIST_VERSION = 1
|
||||
|
||||
/** Number of TCF purposes defined in the specification. */
|
||||
private const val TCF_PURPOSE_COUNT = 24
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Encodes a TC string for the given consent state and site configuration.
|
||||
*
|
||||
* @param state The resolved consent state containing accepted/rejected categories.
|
||||
* @param config The site configuration (used for CMP metadata).
|
||||
* @return A Base64url-encoded TC string, or `null` if the state has no interaction.
|
||||
*/
|
||||
fun encode(state: ConsentState, config: ConsentConfig): String? {
|
||||
if (!state.hasInteracted) return null
|
||||
val consentedAtMs = state.consentedAtMs ?: return null
|
||||
|
||||
// Derive the set of consented TCF purpose IDs from accepted categories.
|
||||
val consentedPurposeIds: Set<Int> = state.accepted
|
||||
.flatMap { it.tcfPurposeIds }
|
||||
.toSet()
|
||||
|
||||
return buildCoreString(consentedAtMs, consentedPurposeIds)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Core String Construction
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun buildCoreString(
|
||||
consentedAtMs: Long,
|
||||
consentedPurposeIds: Set<Int>,
|
||||
): String {
|
||||
val bits = BitWriter()
|
||||
|
||||
// --- Core segment fields (IAB TCF v2.2 spec, Table 1) ---
|
||||
|
||||
// Version (6 bits)
|
||||
bits.write(SPEC_VERSION, 6)
|
||||
|
||||
// Created — deciseconds since epoch (36 bits)
|
||||
val deciseconds = (consentedAtMs / 100).toInt()
|
||||
bits.write(deciseconds, 36)
|
||||
|
||||
// LastUpdated — deciseconds since epoch (36 bits)
|
||||
bits.write(deciseconds, 36)
|
||||
|
||||
// CmpId (12 bits)
|
||||
bits.write(CMP_ID, 12)
|
||||
|
||||
// CmpVersion (12 bits)
|
||||
bits.write(CMP_VERSION, 12)
|
||||
|
||||
// ConsentScreen (6 bits) — screen number within the CMP UI
|
||||
bits.write(1, 6)
|
||||
|
||||
// ConsentLanguage (12 bits) — two 6-bit characters, A=0 … Z=25
|
||||
val (langFirst, langSecond) = encodeTwoLetterCode(CONSENT_LANGUAGE)
|
||||
bits.write(langFirst, 6)
|
||||
bits.write(langSecond, 6)
|
||||
|
||||
// VendorListVersion (12 bits)
|
||||
bits.write(VENDOR_LIST_VERSION, 12)
|
||||
|
||||
// TcfPolicyVersion (6 bits) — must be 4 for TCF v2.2
|
||||
bits.write(4, 6)
|
||||
|
||||
// IsServiceSpecific (1 bit)
|
||||
bits.write(0, 1)
|
||||
|
||||
// UseNonStandardTexts (1 bit)
|
||||
bits.write(0, 1)
|
||||
|
||||
// SpecialFeatureOptIns (12 bits) — none opted in
|
||||
bits.write(0, 12)
|
||||
|
||||
// PurposesConsent (24 bits) — one bit per purpose, purpose 1 first
|
||||
for (purposeId in 1..TCF_PURPOSE_COUNT) {
|
||||
bits.write(if (purposeId in consentedPurposeIds) 1 else 0, 1)
|
||||
}
|
||||
|
||||
// PurposesLITransparency (24 bits) — legitimate interest; none asserted
|
||||
bits.write(0, 24)
|
||||
|
||||
// PurposeOneTreatment (1 bit)
|
||||
bits.write(0, 1)
|
||||
|
||||
// PublisherCC (12 bits) — "GB"
|
||||
val (ccFirst, ccSecond) = encodeTwoLetterCode("GB")
|
||||
bits.write(ccFirst, 6)
|
||||
bits.write(ccSecond, 6)
|
||||
|
||||
// Vendor Consents — BitRange encoding with MaxVendorId = 0 (no vendors)
|
||||
bits.write(0, 16) // MaxVendorId
|
||||
bits.write(0, 1) // IsRangeEncoding = false
|
||||
|
||||
// Vendor Legitimate Interests — MaxVendorId = 0
|
||||
bits.write(0, 16)
|
||||
bits.write(0, 1)
|
||||
|
||||
// Publisher Restrictions count = 0
|
||||
bits.write(0, 12)
|
||||
|
||||
return base64UrlEncode(bits.toByteArray())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Encodes a two-letter language/country code into two 6-bit integers (A=0, Z=25).
|
||||
*/
|
||||
private fun encodeTwoLetterCode(code: String): Pair<Int, Int> {
|
||||
val upper = code.uppercase()
|
||||
return if (upper.length >= 2) {
|
||||
val first = upper[0].code - 'A'.code
|
||||
val second = upper[1].code - 'A'.code
|
||||
Pair(first.coerceIn(0, 25), second.coerceIn(0, 25))
|
||||
} else {
|
||||
Pair(4, 13) // "EN" fallback (E=4, N=13)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a [ByteArray] to a Base64url string (RFC 4648, no padding).
|
||||
*/
|
||||
private fun base64UrlEncode(data: ByteArray): String =
|
||||
Base64.encodeToString(data, Base64.NO_PADDING or Base64.NO_WRAP or Base64.URL_SAFE)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Bit Writer
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* A utility class for packing integers into a bit-level byte buffer.
|
||||
*
|
||||
* Bits are written from the most-significant bit of each byte first (big-endian).
|
||||
*/
|
||||
private class BitWriter {
|
||||
|
||||
private val bytes = mutableListOf<Byte>()
|
||||
private var currentByte: Int = 0
|
||||
private var bitPosition: Int = 0 // 0 = MSB of current byte
|
||||
|
||||
/**
|
||||
* Writes [bitCount] bits from [value], starting from the most-significant bit.
|
||||
*/
|
||||
fun write(value: Int, bitCount: Int) {
|
||||
for (i in bitCount - 1 downTo 0) {
|
||||
val bit = (value shr i) and 1
|
||||
currentByte = currentByte or (bit shl (7 - bitPosition))
|
||||
bitPosition++
|
||||
if (bitPosition == 8) {
|
||||
bytes.add(currentByte.toByte())
|
||||
currentByte = 0
|
||||
bitPosition = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes any remaining partial byte and returns the accumulated bytes.
|
||||
*/
|
||||
fun toByteArray(): ByteArray {
|
||||
val result = bytes.toMutableList()
|
||||
if (bitPosition > 0) result.add(currentByte.toByte())
|
||||
return result.toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.consentos.ui
|
||||
|
||||
import android.graphics.Color
|
||||
import androidx.annotation.ColorInt
|
||||
import com.consentos.ConsentConfig
|
||||
|
||||
/**
|
||||
* Resolved colour and dimension theme for the consent banner.
|
||||
*
|
||||
* Derived from [ConsentConfig.BannerConfig] values, falling back to sensible defaults
|
||||
* when the API config is absent.
|
||||
*/
|
||||
data class BannerTheme(
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Colours (as ARGB packed ints)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@ColorInt val backgroundColor: Int,
|
||||
@ColorInt val textColor: Int,
|
||||
@ColorInt val accentColor: Int,
|
||||
@ColorInt val secondaryTextColor: Int,
|
||||
@ColorInt val dividerColor: Int,
|
||||
@ColorInt val acceptButtonBackground: Int,
|
||||
@ColorInt val acceptButtonTextColor: Int,
|
||||
@ColorInt val rejectButtonBackground: Int,
|
||||
@ColorInt val rejectButtonTextColor: Int,
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Dimensions (dp)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
val cornerRadiusDp: Float,
|
||||
val horizontalPaddingDp: Float,
|
||||
val verticalPaddingDp: Float,
|
||||
val buttonHeightDp: Float,
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Button / Copy Labels
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
val acceptButtonText: String,
|
||||
val rejectButtonText: String,
|
||||
val manageButtonText: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
// Default colour constants (matches iOS SDK defaults for consistency).
|
||||
private const val DEFAULT_ACCENT_HEX = "#1A73E8"
|
||||
private const val DEFAULT_BACKGROUND_HEX = "#FFFFFF"
|
||||
private const val DEFAULT_TEXT_HEX = "#1A1A1A"
|
||||
private const val DEFAULT_SECONDARY_ALPHA = 0x99 // ~60% opacity
|
||||
|
||||
/**
|
||||
* Creates a [BannerTheme] from the banner configuration returned by the API.
|
||||
*
|
||||
* Hex values not present in the config fall back to neutral defaults.
|
||||
*
|
||||
* @param config The [ConsentConfig.BannerConfig] from the API response.
|
||||
*/
|
||||
fun from(config: ConsentConfig.BannerConfig): BannerTheme {
|
||||
val background = parseHex(config.backgroundColor, DEFAULT_BACKGROUND_HEX)
|
||||
val text = parseHex(config.textColor, DEFAULT_TEXT_HEX)
|
||||
val accent = parseHex(config.accentColor, DEFAULT_ACCENT_HEX)
|
||||
|
||||
// Secondary text: same hue as primary but with reduced opacity.
|
||||
val secondaryText = Color.argb(
|
||||
DEFAULT_SECONDARY_ALPHA,
|
||||
Color.red(text),
|
||||
Color.green(text),
|
||||
Color.blue(text),
|
||||
)
|
||||
|
||||
// Divider: very low opacity version of the text colour.
|
||||
val divider = Color.argb(
|
||||
0x1F, // ~12% opacity
|
||||
Color.red(text),
|
||||
Color.green(text),
|
||||
Color.blue(text),
|
||||
)
|
||||
|
||||
// Reject button: light grey background.
|
||||
val rejectBackground = Color.argb(0xFF, 0xF2, 0xF2, 0xF2)
|
||||
|
||||
return BannerTheme(
|
||||
backgroundColor = background,
|
||||
textColor = text,
|
||||
accentColor = accent,
|
||||
secondaryTextColor = secondaryText,
|
||||
dividerColor = divider,
|
||||
acceptButtonBackground = accent,
|
||||
acceptButtonTextColor = Color.WHITE,
|
||||
rejectButtonBackground = rejectBackground,
|
||||
rejectButtonTextColor = text,
|
||||
cornerRadiusDp = 16f,
|
||||
horizontalPaddingDp = 16f,
|
||||
verticalPaddingDp = 16f,
|
||||
buttonHeightDp = 48f,
|
||||
acceptButtonText = config.acceptButtonText ?: "Accept All",
|
||||
rejectButtonText = config.rejectButtonText ?: "Reject All",
|
||||
manageButtonText = config.manageButtonText ?: "Manage Preferences",
|
||||
title = config.title ?: "We value your privacy",
|
||||
description = config.description
|
||||
?: "We use cookies to improve your experience and for analytics.",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Default theme used when no configuration has been loaded yet.
|
||||
*/
|
||||
val default: BannerTheme by lazy {
|
||||
from(ConsentConfig.BannerConfig())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parses a hex colour string such as `"#1A73E8"` or `"1A73E8"`.
|
||||
*
|
||||
* Returns the [fallback] colour if the string is null or malformed.
|
||||
*/
|
||||
@ColorInt
|
||||
private fun parseHex(hex: String?, fallbackHex: String): Int {
|
||||
val input = hex?.trimStart('#') ?: return Color.parseColor(fallbackHex)
|
||||
return runCatching { Color.parseColor("#$input") }
|
||||
.getOrDefault(Color.parseColor(fallbackHex))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.consentos.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.consentos.ConsentOS
|
||||
|
||||
/**
|
||||
* A transparent, trampoline [AppCompatActivity] that hosts the [ConsentBottomSheet].
|
||||
*
|
||||
* This activity is used when the host app cannot directly access a [FragmentManager]
|
||||
* (e.g. from a Service or Application context). Launch it with [launch]:
|
||||
*
|
||||
* ```kotlin
|
||||
* ConsentBannerActivity.launch(context)
|
||||
* ```
|
||||
*
|
||||
* The activity is declared as transparent (`Theme.AppCompat.Translucent`) so only
|
||||
* the bottom sheet is visible. It finishes automatically when the sheet is dismissed.
|
||||
*/
|
||||
class ConsentBannerActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Launches the [ConsentBannerActivity] from any [Context].
|
||||
*
|
||||
* @param context The context from which to start the activity.
|
||||
*/
|
||||
fun launch(context: Context) {
|
||||
val intent = Intent(context, ConsentBannerActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
val config = ConsentOS.instance.siteConfig
|
||||
val sheet = ConsentBottomSheet.newInstance(config)
|
||||
|
||||
sheet.show(supportFragmentManager, "cmp_consent_banner")
|
||||
|
||||
// Finish the activity when the sheet is dismissed.
|
||||
supportFragmentManager.setFragmentResultListener(
|
||||
"cmp_consent_result",
|
||||
this,
|
||||
) { _, _ -> finish() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
// Prevent dismissal via back press without explicit user choice.
|
||||
// The user must interact with the banner buttons.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package com.consentos.ui
|
||||
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.CompoundButton
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Switch
|
||||
import android.widget.TextView
|
||||
import com.consentos.ConsentOS
|
||||
import com.consentos.ConsentCategory
|
||||
import com.consentos.ConsentConfig
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Displays the consent banner as a Material [BottomSheetDialogFragment].
|
||||
*
|
||||
* The sheet is themed from the [ConsentConfig.BannerConfig] passed at construction
|
||||
* (or from defaults if no config is available). It provides three primary actions:
|
||||
*
|
||||
* - **Accept All** — grants all non-necessary categories.
|
||||
* - **Reject All** — denies all non-necessary categories.
|
||||
* - **Manage Preferences** — reveals per-category toggles.
|
||||
*
|
||||
* Use [newInstance] to create an instance and [show] to display it:
|
||||
*
|
||||
* ```kotlin
|
||||
* val sheet = ConsentBottomSheet.newInstance(config)
|
||||
* sheet.show(supportFragmentManager, "cmp_consent_banner")
|
||||
* ```
|
||||
*/
|
||||
class ConsentBottomSheet : BottomSheetDialogFragment() {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Factory
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
companion object {
|
||||
private const val ARG_CONFIG_JSON = "config_json"
|
||||
|
||||
/**
|
||||
* Creates a [ConsentBottomSheet] with the given site configuration.
|
||||
*
|
||||
* @param config The effective site configuration, or `null` to use defaults.
|
||||
*/
|
||||
fun newInstance(config: ConsentConfig?): ConsentBottomSheet {
|
||||
val sheet = ConsentBottomSheet()
|
||||
if (config != null) {
|
||||
val bundle = Bundle()
|
||||
bundle.putString(ARG_CONFIG_JSON, serializeConfig(config))
|
||||
sheet.arguments = bundle
|
||||
}
|
||||
return sheet
|
||||
}
|
||||
|
||||
private fun serializeConfig(config: ConsentConfig): String =
|
||||
// Simple serialisation — uses the default Json instance for round-tripping.
|
||||
runCatching {
|
||||
Json.encodeToString(ConsentConfig.serializer(), config)
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// State
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private var theme: BannerTheme = BannerTheme.default
|
||||
private var config: ConsentConfig? = null
|
||||
|
||||
/** Coroutine scope tied to the fragment's lifecycle. */
|
||||
private val sheetScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val configJson = arguments?.getString(ARG_CONFIG_JSON)
|
||||
if (!configJson.isNullOrEmpty()) {
|
||||
config = runCatching {
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
}.decodeFromString(ConsentConfig.serializer(), configJson)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
// Fall back to the config cached in the singleton if none was passed.
|
||||
if (config == null) {
|
||||
config = ConsentOS.instance.siteConfig
|
||||
}
|
||||
|
||||
theme = config?.bannerConfig?.let { BannerTheme.from(it) } ?: BannerTheme.default
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View = buildBannerView()
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
sheetScope.cancel()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// View Construction (programmatic — no XML layout dependency)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Builds the banner view programmatically to keep the SDK self-contained
|
||||
* and avoid requiring host apps to merge resource files.
|
||||
*/
|
||||
private fun buildBannerView(): View {
|
||||
val ctx = requireContext()
|
||||
|
||||
// Root container
|
||||
val root = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setBackgroundColor(theme.backgroundColor)
|
||||
val dp16 = dpToPx(theme.horizontalPaddingDp).toInt()
|
||||
setPadding(dp16, dp16, dp16, dp16)
|
||||
background = GradientDrawable().apply {
|
||||
setColor(theme.backgroundColor)
|
||||
cornerRadii = floatArrayOf(
|
||||
dpToPx(theme.cornerRadiusDp), dpToPx(theme.cornerRadiusDp),
|
||||
dpToPx(theme.cornerRadiusDp), dpToPx(theme.cornerRadiusDp),
|
||||
0f, 0f,
|
||||
0f, 0f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Title
|
||||
val titleView = TextView(ctx).apply {
|
||||
text = theme.title
|
||||
textSize = 18f
|
||||
setTextColor(theme.textColor)
|
||||
val lp = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
lp.bottomMargin = dpToPx(8f).toInt()
|
||||
layoutParams = lp
|
||||
}
|
||||
root.addView(titleView)
|
||||
|
||||
// Description
|
||||
val descriptionView = TextView(ctx).apply {
|
||||
text = theme.description
|
||||
textSize = 14f
|
||||
setTextColor(theme.secondaryTextColor)
|
||||
val lp = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
lp.bottomMargin = dpToPx(16f).toInt()
|
||||
layoutParams = lp
|
||||
}
|
||||
root.addView(descriptionView)
|
||||
|
||||
// Accept All button
|
||||
val acceptButton = buildPrimaryButton(theme.acceptButtonText).apply {
|
||||
setBackgroundColor(theme.acceptButtonBackground)
|
||||
setTextColor(theme.acceptButtonTextColor)
|
||||
setOnClickListener {
|
||||
sheetScope.launch {
|
||||
ConsentOS.instance.acceptAll()
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
}
|
||||
root.addView(acceptButton)
|
||||
|
||||
val spacer1 = View(ctx).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, dpToPx(8f).toInt(),
|
||||
)
|
||||
}
|
||||
root.addView(spacer1)
|
||||
|
||||
// Reject All button
|
||||
val rejectButton = buildSecondaryButton(theme.rejectButtonText).apply {
|
||||
setBackgroundColor(theme.rejectButtonBackground)
|
||||
setTextColor(theme.rejectButtonTextColor)
|
||||
setOnClickListener {
|
||||
sheetScope.launch {
|
||||
ConsentOS.instance.rejectAll()
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
}
|
||||
root.addView(rejectButton)
|
||||
|
||||
val spacer2 = View(ctx).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, dpToPx(8f).toInt(),
|
||||
)
|
||||
}
|
||||
root.addView(spacer2)
|
||||
|
||||
// Manage Preferences button
|
||||
val manageButton = buildSecondaryButton(theme.manageButtonText).apply {
|
||||
setBackgroundColor(theme.rejectButtonBackground)
|
||||
setTextColor(theme.accentColor) // Use accent colour for the manage button text
|
||||
setOnClickListener { showPreferencesPanel(root) }
|
||||
}
|
||||
root.addView(manageButton)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a per-category preferences panel below the action buttons.
|
||||
*
|
||||
* This is a simplified in-line expansion; a production implementation would open
|
||||
* a separate screen or expand via a RecyclerView with toggle switches.
|
||||
*/
|
||||
private fun showPreferencesPanel(root: LinearLayout) {
|
||||
val ctx = root.context
|
||||
val categories = config?.enabledCategories
|
||||
?: ConsentCategory.entries.filter { it.requiresConsent }
|
||||
|
||||
val panelTitle = TextView(ctx).apply {
|
||||
text = "Manage Preferences"
|
||||
textSize = 16f
|
||||
setTextColor(theme.textColor)
|
||||
val lp = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
lp.topMargin = dpToPx(16f).toInt()
|
||||
lp.bottomMargin = dpToPx(8f).toInt()
|
||||
layoutParams = lp
|
||||
}
|
||||
root.addView(panelTitle)
|
||||
|
||||
// Track which categories the user has toggled (start from current state).
|
||||
val selectedCategories = ConsentOS.instance.consentState?.accepted?.toMutableSet()
|
||||
?: mutableSetOf()
|
||||
|
||||
categories.filter { it.requiresConsent }.forEach { category ->
|
||||
val row = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
val lp = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
lp.bottomMargin = dpToPx(8f).toInt()
|
||||
layoutParams = lp
|
||||
}
|
||||
|
||||
val label = TextView(ctx).apply {
|
||||
text = category.displayName
|
||||
textSize = 14f
|
||||
setTextColor(theme.textColor)
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
row.addView(label)
|
||||
|
||||
@Suppress("DEPRECATION") // Switch is deprecated in API 29+ but works on API 26+
|
||||
val toggle = Switch(ctx).apply {
|
||||
isChecked = category in selectedCategories
|
||||
setOnCheckedChangeListener { _: CompoundButton, checked: Boolean ->
|
||||
if (checked) selectedCategories.add(category)
|
||||
else selectedCategories.remove(category)
|
||||
}
|
||||
}
|
||||
row.addView(toggle)
|
||||
|
||||
root.addView(row)
|
||||
}
|
||||
|
||||
// Save preferences button
|
||||
val saveButton = buildPrimaryButton("Save Preferences").apply {
|
||||
setBackgroundColor(theme.acceptButtonBackground)
|
||||
setTextColor(theme.acceptButtonTextColor)
|
||||
val lp = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, dpToPx(theme.buttonHeightDp).toInt(),
|
||||
)
|
||||
lp.topMargin = dpToPx(8f).toInt()
|
||||
layoutParams = lp
|
||||
setOnClickListener {
|
||||
sheetScope.launch {
|
||||
ConsentOS.instance.acceptCategories(selectedCategories.toList())
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
}
|
||||
root.addView(saveButton)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun buildPrimaryButton(label: String) = Button(requireContext()).apply {
|
||||
text = label
|
||||
isAllCaps = false
|
||||
textSize = 14f
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
dpToPx(theme.buttonHeightDp).toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildSecondaryButton(label: String) = Button(requireContext()).apply {
|
||||
text = label
|
||||
isAllCaps = false
|
||||
textSize = 14f
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
dpToPx(theme.buttonHeightDp).toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Converts dp to pixels using the current display density. */
|
||||
private fun dpToPx(dp: Float): Float =
|
||||
dp * (requireContext().resources.displayMetrics.density)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package com.consentos
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for the [ConsentAPIProtocol] contract using a mock implementation.
|
||||
*
|
||||
* These tests verify the API contract and [ConsentPayload] serialisation logic
|
||||
* without making real network calls.
|
||||
*/
|
||||
class ConsentAPITest {
|
||||
|
||||
private lateinit var mockAPI: MockConsentAPI
|
||||
private val siteId = "test-site-001"
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockAPI = MockConsentAPI()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// fetchConfig
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `fetchConfig returns config when successful`() = runTest {
|
||||
mockAPI.configToReturn = makeSampleConfig()
|
||||
|
||||
val result = mockAPI.fetchConfig(siteId)
|
||||
|
||||
assertEquals(siteId, result.siteId)
|
||||
assertEquals(1, mockAPI.fetchConfigCallCount)
|
||||
assertEquals(siteId, mockAPI.lastFetchedSiteId)
|
||||
}
|
||||
|
||||
@Test(expected = ConsentAPIException.NetworkFailure::class)
|
||||
fun `fetchConfig throws NetworkFailure on network error`() = runTest {
|
||||
mockAPI.errorToThrow = ConsentAPIException.NetworkFailure(RuntimeException("No connectivity"))
|
||||
mockAPI.fetchConfig(siteId)
|
||||
}
|
||||
|
||||
@Test(expected = ConsentAPIException.UnexpectedStatusCode::class)
|
||||
fun `fetchConfig throws UnexpectedStatusCode on 404`() = runTest {
|
||||
mockAPI.errorToThrow = ConsentAPIException.UnexpectedStatusCode(404)
|
||||
mockAPI.fetchConfig(siteId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetchConfig throws exception with correct status code`() = runTest {
|
||||
mockAPI.errorToThrow = ConsentAPIException.UnexpectedStatusCode(503)
|
||||
try {
|
||||
mockAPI.fetchConfig(siteId)
|
||||
} catch (e: ConsentAPIException.UnexpectedStatusCode) {
|
||||
assertEquals(503, e.code)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// postConsent
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `postConsent sends correct payload`() = runTest {
|
||||
val state = ConsentState(
|
||||
visitorId = "visitor-xyz",
|
||||
accepted = setOf(ConsentCategory.ANALYTICS, ConsentCategory.FUNCTIONAL),
|
||||
rejected = setOf(ConsentCategory.MARKETING),
|
||||
consentedAtMs = 1_700_000_000_000L,
|
||||
bannerVersion = "v2",
|
||||
)
|
||||
val payload = ConsentPayload.from(
|
||||
siteId = siteId,
|
||||
state = state,
|
||||
tcString = "test-tc-string",
|
||||
)!!
|
||||
|
||||
mockAPI.postConsent(payload)
|
||||
|
||||
assertEquals(1, mockAPI.postConsentCallCount)
|
||||
val sent = mockAPI.lastPostedPayload!!
|
||||
assertEquals(siteId, sent.siteId)
|
||||
assertEquals("visitor-xyz", sent.visitorId)
|
||||
assertEquals("android", sent.platform)
|
||||
assertTrue(sent.accepted.contains("analytics"))
|
||||
assertTrue(sent.accepted.contains("functional"))
|
||||
assertTrue(sent.rejected.contains("marketing"))
|
||||
assertEquals("v2", sent.bannerVersion)
|
||||
assertEquals("test-tc-string", sent.tcString)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `postConsent platform is always android`() = runTest {
|
||||
val state = ConsentState(visitorId = "v", consentedAtMs = System.currentTimeMillis())
|
||||
val payload = ConsentPayload.from(siteId = siteId, state = state)!!
|
||||
|
||||
mockAPI.postConsent(payload)
|
||||
|
||||
assertEquals("android", mockAPI.lastPostedPayload?.platform)
|
||||
}
|
||||
|
||||
@Test(expected = ConsentAPIException.UnexpectedStatusCode::class)
|
||||
fun `postConsent throws on server error`() = runTest {
|
||||
mockAPI.postConsentError = ConsentAPIException.UnexpectedStatusCode(500)
|
||||
val payload = ConsentPayload.from(
|
||||
siteId = siteId,
|
||||
state = ConsentState(visitorId = "v", consentedAtMs = System.currentTimeMillis()),
|
||||
)!!
|
||||
mockAPI.postConsent(payload)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ConsentPayload construction
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `ConsentPayload from encodes categories to raw values`() {
|
||||
val state = ConsentState(
|
||||
visitorId = "v1",
|
||||
accepted = setOf(ConsentCategory.ANALYTICS, ConsentCategory.MARKETING),
|
||||
rejected = setOf(ConsentCategory.FUNCTIONAL),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
val payload = ConsentPayload.from(siteId = "s1", state = state)!!
|
||||
|
||||
assertTrue(payload.accepted.contains("analytics"))
|
||||
assertTrue(payload.accepted.contains("marketing"))
|
||||
assertTrue(payload.rejected.contains("functional"))
|
||||
assertFalse(payload.accepted.contains("necessary"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ConsentPayload from returns null when no consentedAtMs`() {
|
||||
val state = ConsentState(visitorId = "v1") // no interaction
|
||||
val payload = ConsentPayload.from(siteId = "s1", state = state)
|
||||
assertTrue(payload == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ConsentPayload consentedAt is formatted as ISO-8601 UTC`() {
|
||||
val ts = 1_700_000_000_000L
|
||||
val state = ConsentState(visitorId = "v1", consentedAtMs = ts)
|
||||
val payload = ConsentPayload.from(siteId = "s1", state = state)!!
|
||||
|
||||
// Should be in the format "2023-11-14T22:13:20Z" (approximately)
|
||||
assertTrue(
|
||||
"consentedAt should match ISO-8601 UTC pattern",
|
||||
payload.consentedAt.matches(Regex("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z")),
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Error messages
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `UnexpectedStatusCode includes code in message`() {
|
||||
val error = ConsentAPIException.UnexpectedStatusCode(503)
|
||||
assertTrue(error.message?.contains("503") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `InvalidURL has non-empty message`() {
|
||||
val error = ConsentAPIException.InvalidURL("not-a-url")
|
||||
assertNotNull(error.message)
|
||||
assertFalse(error.message!!.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NetworkFailure wraps original cause`() {
|
||||
val cause = RuntimeException("timeout")
|
||||
val error = ConsentAPIException.NetworkFailure(cause)
|
||||
assertEquals(cause, error.cause)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun makeSampleConfig() = ConsentConfig(
|
||||
siteId = siteId,
|
||||
siteName = "Test Site",
|
||||
blockingMode = ConsentConfig.BlockingMode.OPT_IN,
|
||||
consentExpiryDays = 365,
|
||||
bannerVersion = "v1",
|
||||
bannerConfig = ConsentConfig.BannerConfig(),
|
||||
categories = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Mock API Implementation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* In-memory [ConsentAPIProtocol] implementation for testing without network calls.
|
||||
*/
|
||||
class MockConsentAPI : ConsentAPIProtocol {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Configurable behaviour
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
var configToReturn: ConsentConfig? = null
|
||||
var errorToThrow: Throwable? = null
|
||||
var postConsentError: Throwable? = null
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Call tracking
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
var fetchConfigCallCount = 0
|
||||
private set
|
||||
var postConsentCallCount = 0
|
||||
private set
|
||||
var lastPostedPayload: ConsentPayload? = null
|
||||
private set
|
||||
var lastFetchedSiteId: String? = null
|
||||
private set
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ConsentAPIProtocol
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
override suspend fun fetchConfig(siteId: String): ConsentConfig {
|
||||
fetchConfigCallCount++
|
||||
lastFetchedSiteId = siteId
|
||||
errorToThrow?.let { throw it }
|
||||
return configToReturn ?: throw ConsentAPIException.UnexpectedStatusCode(404)
|
||||
}
|
||||
|
||||
override suspend fun postConsent(payload: ConsentPayload) {
|
||||
postConsentCallCount++
|
||||
lastPostedPayload = payload
|
||||
postConsentError?.let { throw it }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.consentos
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for [ConsentCategory].
|
||||
*/
|
||||
class ConsentCategoryTest {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Raw Values
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `necessary has expected raw value`() {
|
||||
assertEquals("necessary", ConsentCategory.NECESSARY.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `functional has expected raw value`() {
|
||||
assertEquals("functional", ConsentCategory.FUNCTIONAL.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `analytics has expected raw value`() {
|
||||
assertEquals("analytics", ConsentCategory.ANALYTICS.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `marketing has expected raw value`() {
|
||||
assertEquals("marketing", ConsentCategory.MARKETING.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `personalisation has expected raw value`() {
|
||||
assertEquals("personalisation", ConsentCategory.PERSONALISATION.value)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// fromValue
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `fromValue returns correct category for known values`() {
|
||||
assertEquals(ConsentCategory.NECESSARY, ConsentCategory.fromValue("necessary"))
|
||||
assertEquals(ConsentCategory.FUNCTIONAL, ConsentCategory.fromValue("functional"))
|
||||
assertEquals(ConsentCategory.ANALYTICS, ConsentCategory.fromValue("analytics"))
|
||||
assertEquals(ConsentCategory.MARKETING, ConsentCategory.fromValue("marketing"))
|
||||
assertEquals(ConsentCategory.PERSONALISATION, ConsentCategory.fromValue("personalisation"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromValue returns null for unknown value`() {
|
||||
assertNull(ConsentCategory.fromValue("unknown_category"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromValue returns null for empty string`() {
|
||||
assertNull(ConsentCategory.fromValue(""))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// requiresConsent
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `necessary does not require consent`() {
|
||||
assertFalse(ConsentCategory.NECESSARY.requiresConsent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all non-necessary categories require consent`() {
|
||||
val nonNecessary = ConsentCategory.entries.filter { it != ConsentCategory.NECESSARY }
|
||||
nonNecessary.forEach { category ->
|
||||
assertTrue("$category should require consent", category.requiresConsent)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TCF Purpose IDs
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `necessary has no TCF purpose IDs`() {
|
||||
assertTrue(ConsentCategory.NECESSARY.tcfPurposeIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `functional maps to TCF purpose 1`() {
|
||||
assertEquals(listOf(1), ConsentCategory.FUNCTIONAL.tcfPurposeIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `analytics maps to TCF purposes 7 8 9 10`() {
|
||||
assertEquals(listOf(7, 8, 9, 10), ConsentCategory.ANALYTICS.tcfPurposeIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `marketing maps to TCF purposes 2 3 4`() {
|
||||
assertEquals(listOf(2, 3, 4), ConsentCategory.MARKETING.tcfPurposeIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `personalisation maps to TCF purposes 5 6`() {
|
||||
assertEquals(listOf(5, 6), ConsentCategory.PERSONALISATION.tcfPurposeIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all non-necessary categories have at least one TCF purpose ID`() {
|
||||
ConsentCategory.entries
|
||||
.filter { it.requiresConsent }
|
||||
.forEach { category ->
|
||||
assertTrue(
|
||||
"$category should have at least one TCF purpose ID",
|
||||
category.tcfPurposeIds.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GCM Consent Types
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `necessary has no GCM consent type`() {
|
||||
assertNull(ConsentCategory.NECESSARY.gcmConsentType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `functional maps to functionality_storage`() {
|
||||
assertEquals("functionality_storage", ConsentCategory.FUNCTIONAL.gcmConsentType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `analytics maps to analytics_storage`() {
|
||||
assertEquals("analytics_storage", ConsentCategory.ANALYTICS.gcmConsentType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `marketing maps to ad_storage`() {
|
||||
assertEquals("ad_storage", ConsentCategory.MARKETING.gcmConsentType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `personalisation maps to personalization_storage`() {
|
||||
assertEquals("personalization_storage", ConsentCategory.PERSONALISATION.gcmConsentType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all non-necessary categories have a GCM consent type`() {
|
||||
ConsentCategory.entries
|
||||
.filter { it.requiresConsent }
|
||||
.forEach { category ->
|
||||
assertNotNull("$category should have a GCM consent type", category.gcmConsentType)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Display
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `all categories have non-empty display names`() {
|
||||
ConsentCategory.entries.forEach { category ->
|
||||
assertTrue(
|
||||
"$category displayName should not be empty",
|
||||
category.displayName.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all categories have non-empty display descriptions`() {
|
||||
ConsentCategory.entries.forEach { category ->
|
||||
assertTrue(
|
||||
"$category displayDescription should not be empty",
|
||||
category.displayDescription.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `five categories are defined`() {
|
||||
assertEquals(5, ConsentCategory.entries.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package com.consentos
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Unit tests for [ConsentOS].
|
||||
*
|
||||
* Uses Robolectric for [Context] and mock dependencies to avoid real network / storage calls.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ConsentOSTest {
|
||||
|
||||
private lateinit var sdk: ConsentOS
|
||||
private lateinit var mockStorage: MockConsentStorage
|
||||
private lateinit var mockAPI: MockConsentAPI
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockStorage = MockConsentStorage()
|
||||
mockAPI = MockConsentAPI()
|
||||
sdk = ConsentOS(
|
||||
storage = mockStorage,
|
||||
api = mockAPI,
|
||||
gcmBridge = GCMBridge(NoOpGCMAnalyticsProvider()),
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// configure
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `configure sets isConfigured`() {
|
||||
assertFalse(sdk.isConfigured)
|
||||
sdk.siteId = "site-001" // Simulate configure without context
|
||||
assertTrue(sdk.isConfigured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `configure restores persisted state`() {
|
||||
val existing = ConsentState(
|
||||
visitorId = mockStorage.storedVisitorId,
|
||||
accepted = setOf(ConsentCategory.ANALYTICS),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
mockStorage.storedState = existing
|
||||
|
||||
// Manually wire up without a real Context (as configure() would do)
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = mockStorage.loadState() ?: ConsentState(mockStorage.visitorId())
|
||||
|
||||
assertEquals(setOf(ConsentCategory.ANALYTICS), sdk.consentState?.accepted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `configure creates new state when no persisted state exists`() {
|
||||
mockStorage.storedState = null
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = mockStorage.loadState() ?: ConsentState(mockStorage.visitorId())
|
||||
|
||||
assertNotNull(sdk.consentState)
|
||||
assertFalse(sdk.consentState!!.hasInteracted)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// shouldShowBanner
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `shouldShowBanner returns true when no interaction`() = runTest {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1") // no interaction
|
||||
|
||||
assertTrue(sdk.shouldShowBanner())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shouldShowBanner returns false when recent consent exists`() = runTest {
|
||||
mockAPI.configToReturn = makeSampleConfig(expiryDays = 365, bannerVersion = "v1")
|
||||
mockStorage.storedCachedConfig = CachedConfig(
|
||||
config = makeSampleConfig(expiryDays = 365, bannerVersion = "v1"),
|
||||
fetchedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(
|
||||
visitorId = "v1",
|
||||
accepted = setOf(ConsentCategory.ANALYTICS),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
bannerVersion = "v1",
|
||||
)
|
||||
sdk.siteConfig = mockStorage.storedCachedConfig!!.config
|
||||
|
||||
assertFalse(sdk.shouldShowBanner())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shouldShowBanner returns true when banner version changed`() = runTest {
|
||||
mockAPI.configToReturn = makeSampleConfig(expiryDays = 365, bannerVersion = "v2")
|
||||
mockStorage.storedCachedConfig = CachedConfig(
|
||||
config = makeSampleConfig(expiryDays = 365, bannerVersion = "v2"),
|
||||
fetchedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(
|
||||
visitorId = "v1",
|
||||
accepted = setOf(ConsentCategory.ANALYTICS),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
bannerVersion = "v1", // old version
|
||||
)
|
||||
sdk.siteConfig = mockStorage.storedCachedConfig!!.config
|
||||
|
||||
assertTrue(sdk.shouldShowBanner())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shouldShowBanner returns true when consent has expired`() = runTest {
|
||||
val expiryDays = 30
|
||||
val expiredTs = System.currentTimeMillis() - (expiryDays * 86_400_000L + 1_000L)
|
||||
|
||||
mockStorage.storedCachedConfig = CachedConfig(
|
||||
config = makeSampleConfig(expiryDays = expiryDays, bannerVersion = "v1"),
|
||||
fetchedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(
|
||||
visitorId = "v1",
|
||||
accepted = setOf(ConsentCategory.ANALYTICS),
|
||||
consentedAtMs = expiredTs,
|
||||
bannerVersion = "v1",
|
||||
)
|
||||
sdk.siteConfig = mockStorage.storedCachedConfig!!.config
|
||||
|
||||
assertTrue(sdk.shouldShowBanner())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// acceptAll
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `acceptAll updates consent state`() = runTest {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
|
||||
sdk.acceptAll()
|
||||
|
||||
val state = sdk.consentState
|
||||
assertNotNull(state)
|
||||
assertTrue(state!!.hasInteracted)
|
||||
assertTrue(state.accepted.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acceptAll grants all optional categories`() = runTest {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
|
||||
sdk.acceptAll()
|
||||
|
||||
ConsentCategory.entries
|
||||
.filter { it.requiresConsent }
|
||||
.forEach { category ->
|
||||
assertTrue("$category should be granted", sdk.getConsentStatus(category))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acceptAll persists to storage`() = runTest {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
|
||||
sdk.acceptAll()
|
||||
|
||||
assertTrue(mockStorage.saveStateCallCount > 0)
|
||||
assertNotNull(mockStorage.storedState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acceptAll posts consent to API`() = runTest {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
|
||||
sdk.acceptAll()
|
||||
|
||||
assertEquals(1, mockAPI.postConsentCallCount)
|
||||
assertEquals("android", mockAPI.lastPostedPayload?.platform)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// rejectAll
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `rejectAll updates consent state`() = runTest {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
|
||||
sdk.rejectAll()
|
||||
|
||||
val state = sdk.consentState
|
||||
assertNotNull(state)
|
||||
assertTrue(state!!.hasInteracted)
|
||||
assertTrue(state.accepted.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejectAll denies all optional categories`() = runTest {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
|
||||
sdk.rejectAll()
|
||||
|
||||
ConsentCategory.entries
|
||||
.filter { it.requiresConsent }
|
||||
.forEach { category ->
|
||||
assertFalse("$category should be denied", sdk.getConsentStatus(category))
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// acceptCategories
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `acceptCategories only grants specified categories`() = runTest {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
|
||||
sdk.acceptCategories(listOf(ConsentCategory.ANALYTICS, ConsentCategory.FUNCTIONAL))
|
||||
|
||||
assertTrue(sdk.getConsentStatus(ConsentCategory.ANALYTICS))
|
||||
assertTrue(sdk.getConsentStatus(ConsentCategory.FUNCTIONAL))
|
||||
assertFalse(sdk.getConsentStatus(ConsentCategory.MARKETING))
|
||||
assertFalse(sdk.getConsentStatus(ConsentCategory.PERSONALISATION))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// getConsentStatus
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `getConsentStatus returns true for necessary always`() {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
assertTrue(sdk.getConsentStatus(ConsentCategory.NECESSARY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConsentStatus returns false for optional before interaction`() {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
assertFalse(sdk.getConsentStatus(ConsentCategory.ANALYTICS))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Listener
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `listener is notified after acceptAll`() = runTest {
|
||||
var notified = false
|
||||
var receivedState: ConsentState? = null
|
||||
|
||||
val listener = object : ConsentOSListener {
|
||||
override fun onConsentChanged(state: ConsentState) {
|
||||
notified = true
|
||||
receivedState = state
|
||||
}
|
||||
}
|
||||
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
sdk.addConsentListener(listener)
|
||||
|
||||
sdk.acceptAll()
|
||||
|
||||
assertTrue(notified)
|
||||
assertNotNull(receivedState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listener is not notified after removal`() = runTest {
|
||||
var callCount = 0
|
||||
val listener = object : ConsentOSListener {
|
||||
override fun onConsentChanged(state: ConsentState) {
|
||||
callCount++
|
||||
}
|
||||
}
|
||||
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
sdk.addConsentListener(listener)
|
||||
sdk.removeConsentListener(listener)
|
||||
|
||||
sdk.acceptAll()
|
||||
|
||||
assertEquals(0, callCount)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun makeSampleConfig(expiryDays: Int = 365, bannerVersion: String = "v1") =
|
||||
ConsentConfig(
|
||||
siteId = "site-001",
|
||||
siteName = "Test",
|
||||
blockingMode = ConsentConfig.BlockingMode.OPT_IN,
|
||||
consentExpiryDays = expiryDays,
|
||||
bannerVersion = bannerVersion,
|
||||
bannerConfig = ConsentConfig.BannerConfig(),
|
||||
categories = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MockConsentStorage
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* In-memory [ConsentStorageProtocol] implementation for testing.
|
||||
*/
|
||||
class MockConsentStorage : ConsentStorageProtocol {
|
||||
|
||||
var storedState: ConsentState? = null
|
||||
var storedCachedConfig: CachedConfig? = null
|
||||
val storedVisitorId: String = "mock-visitor-${System.currentTimeMillis()}"
|
||||
|
||||
var saveStateCallCount = 0
|
||||
private set
|
||||
var clearStateCallCount = 0
|
||||
private set
|
||||
|
||||
override fun loadState(): ConsentState? = storedState
|
||||
|
||||
override fun saveState(state: ConsentState) {
|
||||
saveStateCallCount++
|
||||
storedState = state
|
||||
}
|
||||
|
||||
override fun clearState() {
|
||||
clearStateCallCount++
|
||||
storedState = null
|
||||
}
|
||||
|
||||
override fun loadCachedConfig(): CachedConfig? = storedCachedConfig
|
||||
|
||||
override fun saveCachedConfig(cached: CachedConfig) {
|
||||
storedCachedConfig = cached
|
||||
}
|
||||
|
||||
override fun clearCachedConfig() {
|
||||
storedCachedConfig = null
|
||||
}
|
||||
|
||||
override fun visitorId(): String = storedVisitorId
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package com.consentos
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for [ConsentState].
|
||||
*/
|
||||
class ConsentStateTest {
|
||||
|
||||
private val visitorId = "test-visitor-123"
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initial State
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `new state has no interaction`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
assertFalse(state.hasInteracted)
|
||||
assertNull(state.consentedAtMs)
|
||||
assertTrue(state.accepted.isEmpty())
|
||||
assertTrue(state.rejected.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new state preserves visitor ID`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
assertEquals(visitorId, state.visitorId)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// isGranted
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `necessary is always granted`() {
|
||||
val state = ConsentState(visitorId = visitorId) // no interaction
|
||||
assertTrue(state.isGranted(ConsentCategory.NECESSARY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `optional categories are not granted when no interaction`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
ConsentCategory.entries
|
||||
.filter { it != ConsentCategory.NECESSARY }
|
||||
.forEach { category ->
|
||||
assertFalse("$category should not be granted by default", state.isGranted(category))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepted category is granted`() {
|
||||
val state = ConsentState(
|
||||
visitorId = visitorId,
|
||||
accepted = setOf(ConsentCategory.ANALYTICS),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
assertTrue(state.isGranted(ConsentCategory.ANALYTICS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected category is not granted`() {
|
||||
val state = ConsentState(
|
||||
visitorId = visitorId,
|
||||
rejected = setOf(ConsentCategory.ANALYTICS),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
assertFalse(state.isGranted(ConsentCategory.ANALYTICS))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// isDenied
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `necessary is never denied`() {
|
||||
val state = ConsentState(
|
||||
visitorId = visitorId,
|
||||
rejected = setOf(ConsentCategory.ANALYTICS),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
assertFalse(state.isDenied(ConsentCategory.NECESSARY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected category is denied`() {
|
||||
val state = ConsentState(
|
||||
visitorId = visitorId,
|
||||
rejected = setOf(ConsentCategory.MARKETING),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
assertTrue(state.isDenied(ConsentCategory.MARKETING))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-rejected category is not denied`() {
|
||||
val state = ConsentState(
|
||||
visitorId = visitorId,
|
||||
accepted = setOf(ConsentCategory.ANALYTICS),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
assertFalse(state.isDenied(ConsentCategory.ANALYTICS))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// acceptingAll()
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `acceptingAll grants all optional categories`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
val accepted = state.acceptingAll()
|
||||
|
||||
val expected = ConsentCategory.entries.filter { it.requiresConsent }.toSet()
|
||||
assertEquals(expected, accepted.accepted)
|
||||
assertTrue(accepted.rejected.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acceptingAll sets consentedAtMs`() {
|
||||
val before = System.currentTimeMillis()
|
||||
val state = ConsentState(visitorId = visitorId).acceptingAll()
|
||||
assertNotNull(state.consentedAtMs)
|
||||
assertTrue(state.consentedAtMs!! >= before)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acceptingAll preserves visitor ID`() {
|
||||
val state = ConsentState(visitorId = visitorId).acceptingAll()
|
||||
assertEquals(visitorId, state.visitorId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acceptingAll marks state as interacted`() {
|
||||
val state = ConsentState(visitorId = visitorId).acceptingAll()
|
||||
assertTrue(state.hasInteracted)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// rejectingAll()
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `rejectingAll empties accepted set`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
val rejected = state.rejectingAll()
|
||||
assertTrue(rejected.accepted.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejectingAll rejects all optional categories`() {
|
||||
val state = ConsentState(visitorId = visitorId).rejectingAll()
|
||||
val expected = ConsentCategory.entries.filter { it.requiresConsent }.toSet()
|
||||
assertEquals(expected, state.rejected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejectingAll sets consentedAtMs`() {
|
||||
val state = ConsentState(visitorId = visitorId).rejectingAll()
|
||||
assertNotNull(state.consentedAtMs)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// accepting(categories)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `accepting only accepts specified categories`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
val result = state.accepting(setOf(ConsentCategory.ANALYTICS, ConsentCategory.FUNCTIONAL))
|
||||
|
||||
assertTrue(result.accepted.contains(ConsentCategory.ANALYTICS))
|
||||
assertTrue(result.accepted.contains(ConsentCategory.FUNCTIONAL))
|
||||
assertFalse(result.accepted.contains(ConsentCategory.MARKETING))
|
||||
assertFalse(result.accepted.contains(ConsentCategory.PERSONALISATION))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepting rejects remainder`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
val result = state.accepting(setOf(ConsentCategory.ANALYTICS))
|
||||
|
||||
assertTrue(result.rejected.contains(ConsentCategory.MARKETING))
|
||||
assertTrue(result.rejected.contains(ConsentCategory.FUNCTIONAL))
|
||||
assertTrue(result.rejected.contains(ConsentCategory.PERSONALISATION))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepting ignores necessary category`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
val result = state.accepting(setOf(ConsentCategory.NECESSARY))
|
||||
assertFalse(result.accepted.contains(ConsentCategory.NECESSARY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepting empty set rejects all`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
val result = state.accepting(emptySet())
|
||||
assertTrue(result.accepted.isEmpty())
|
||||
val expectedRejected = ConsentCategory.entries.filter { it.requiresConsent }.toSet()
|
||||
assertEquals(expectedRejected, result.rejected)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Serialisation (kotlinx.serialization)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `state round-trips via JSON`() {
|
||||
val original = ConsentState(
|
||||
visitorId = visitorId,
|
||||
accepted = setOf(ConsentCategory.ANALYTICS, ConsentCategory.FUNCTIONAL),
|
||||
rejected = setOf(ConsentCategory.MARKETING),
|
||||
consentedAtMs = 1_700_000_000_000L,
|
||||
bannerVersion = "v2",
|
||||
)
|
||||
|
||||
val encoded = json.encodeToString(original)
|
||||
val decoded = json.decodeFromString<ConsentState>(encoded)
|
||||
|
||||
assertEquals(original.visitorId, decoded.visitorId)
|
||||
assertEquals(original.accepted, decoded.accepted)
|
||||
assertEquals(original.rejected, decoded.rejected)
|
||||
assertEquals(original.consentedAtMs, decoded.consentedAtMs)
|
||||
assertEquals(original.bannerVersion, decoded.bannerVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty state serialises without error`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
val encoded = json.encodeToString(state)
|
||||
assertNotNull(encoded)
|
||||
assertTrue(encoded.isNotEmpty())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Equality
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `two states with same values are equal`() {
|
||||
val ts = 1_000_000L
|
||||
val a = ConsentState(visitorId, setOf(ConsentCategory.ANALYTICS), emptySet(), ts, "v1")
|
||||
val b = ConsentState(visitorId, setOf(ConsentCategory.ANALYTICS), emptySet(), ts, "v1")
|
||||
assertEquals(a, b)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two states with different accepted are not equal`() {
|
||||
val ts = 1_000_000L
|
||||
val a = ConsentState(visitorId, setOf(ConsentCategory.ANALYTICS), emptySet(), ts, null)
|
||||
val b = ConsentState(visitorId, setOf(ConsentCategory.MARKETING), emptySet(), ts, null)
|
||||
assertTrue(a != b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.consentos
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Unit tests for [ConsentStorage].
|
||||
*
|
||||
* Robolectric is used to provide an Android [Context] and emulate
|
||||
* [EncryptedSharedPreferences] without a device or emulator.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ConsentStorageTest {
|
||||
|
||||
private lateinit var storage: ConsentStorage
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
storage = ConsentStorage(context)
|
||||
// Wipe state between tests.
|
||||
storage.clearState()
|
||||
storage.clearCachedConfig()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ConsentState
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `loadState returns null when no state is stored`() {
|
||||
assertNull(storage.loadState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveState and loadState round-trip a ConsentState`() {
|
||||
val original = ConsentState(
|
||||
visitorId = "visitor-abc",
|
||||
accepted = setOf(ConsentCategory.ANALYTICS, ConsentCategory.FUNCTIONAL),
|
||||
rejected = setOf(ConsentCategory.MARKETING),
|
||||
consentedAtMs = 1_700_000_000_000L,
|
||||
bannerVersion = "v2",
|
||||
)
|
||||
|
||||
storage.saveState(original)
|
||||
val loaded = storage.loadState()
|
||||
|
||||
assertNotNull(loaded)
|
||||
assertEquals(original.visitorId, loaded!!.visitorId)
|
||||
assertEquals(original.accepted, loaded.accepted)
|
||||
assertEquals(original.rejected, loaded.rejected)
|
||||
assertEquals(original.consentedAtMs, loaded.consentedAtMs)
|
||||
assertEquals(original.bannerVersion, loaded.bannerVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearState removes stored state`() {
|
||||
val state = ConsentState(visitorId = "v1").acceptingAll()
|
||||
storage.saveState(state)
|
||||
storage.clearState()
|
||||
assertNull(storage.loadState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveState overwrites previous state`() {
|
||||
val first = ConsentState(visitorId = "v1").acceptingAll()
|
||||
val second = ConsentState(visitorId = "v1").rejectingAll()
|
||||
|
||||
storage.saveState(first)
|
||||
storage.saveState(second)
|
||||
|
||||
val loaded = storage.loadState()
|
||||
assertNotNull(loaded)
|
||||
assertTrue(loaded!!.accepted.isEmpty())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CachedConfig
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `loadCachedConfig returns null when nothing is stored`() {
|
||||
assertNull(storage.loadCachedConfig())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveCachedConfig and loadCachedConfig round-trip a CachedConfig`() {
|
||||
val config = makeSampleConfig()
|
||||
val cached = CachedConfig(config = config, fetchedAtMs = 1_700_000_000_000L)
|
||||
|
||||
storage.saveCachedConfig(cached)
|
||||
val loaded = storage.loadCachedConfig()
|
||||
|
||||
assertNotNull(loaded)
|
||||
assertEquals(cached.config.siteId, loaded!!.config.siteId)
|
||||
assertEquals(cached.fetchedAtMs, loaded.fetchedAtMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearCachedConfig removes stored config`() {
|
||||
val config = makeSampleConfig()
|
||||
storage.saveCachedConfig(CachedConfig(config = config, fetchedAtMs = System.currentTimeMillis()))
|
||||
storage.clearCachedConfig()
|
||||
assertNull(storage.loadCachedConfig())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Visitor ID
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `visitorId returns a non-empty UUID-like string`() {
|
||||
val id = storage.visitorId()
|
||||
assertNotNull(id)
|
||||
assertTrue(id.isNotEmpty())
|
||||
// UUIDs are 36 characters: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
assertEquals(36, id.length)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visitorId returns the same value on subsequent calls`() {
|
||||
val first = storage.visitorId()
|
||||
val second = storage.visitorId()
|
||||
assertEquals(first, second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two distinct storage instances share the same visitor ID`() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val storage2 = ConsentStorage(context)
|
||||
val id1 = storage.visitorId()
|
||||
val id2 = storage2.visitorId()
|
||||
assertEquals(id1, id2)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CachedConfig Expiry
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `CachedConfig is not expired when freshly created`() {
|
||||
val cached = CachedConfig(
|
||||
config = makeSampleConfig(),
|
||||
fetchedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
assertTrue(!cached.isExpired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CachedConfig is expired when older than TTL`() {
|
||||
val tooOldMs = System.currentTimeMillis() - CachedConfig.TTL_MS - 1_000L
|
||||
val cached = CachedConfig(
|
||||
config = makeSampleConfig(),
|
||||
fetchedAtMs = tooOldMs,
|
||||
)
|
||||
assertTrue(cached.isExpired)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun makeSampleConfig() = ConsentConfig(
|
||||
siteId = "site-001",
|
||||
siteName = "Test Site",
|
||||
blockingMode = ConsentConfig.BlockingMode.OPT_IN,
|
||||
consentExpiryDays = 365,
|
||||
bannerVersion = "v1",
|
||||
bannerConfig = ConsentConfig.BannerConfig(),
|
||||
categories = emptyList(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.consentos
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.Base64
|
||||
|
||||
/**
|
||||
* Unit tests for [TCFStringEncoder].
|
||||
*
|
||||
* Robolectric is used here because [TCFStringEncoder] uses [android.util.Base64].
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class TCFStringEncoderTest {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Returns null before interaction
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `encode returns null when state has no interaction`() {
|
||||
val state = ConsentState(visitorId = "v1") // consentedAtMs is null
|
||||
val config = makeSampleConfig()
|
||||
assertNull(TCFStringEncoder.encode(state, config))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Returns a non-empty string after interaction
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `encode returns non-empty string after acceptAll`() {
|
||||
val state = ConsentState(visitorId = "v1").acceptingAll()
|
||||
val config = makeSampleConfig()
|
||||
val result = TCFStringEncoder.encode(state, config)
|
||||
assertNotNull(result)
|
||||
assertTrue(result!!.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encode returns non-empty string after rejectAll`() {
|
||||
val state = ConsentState(visitorId = "v1").rejectingAll()
|
||||
val config = makeSampleConfig()
|
||||
val result = TCFStringEncoder.encode(state, config)
|
||||
assertNotNull(result)
|
||||
assertTrue(result!!.isNotEmpty())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Base64url format
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `encode produces valid Base64url without plus signs`() {
|
||||
val state = ConsentState(visitorId = "v1").acceptingAll()
|
||||
val tcString = TCFStringEncoder.encode(state, makeSampleConfig())!!
|
||||
assertFalse("TC string must not contain '+'", tcString.contains('+'))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encode produces valid Base64url without forward slashes`() {
|
||||
val state = ConsentState(visitorId = "v1").acceptingAll()
|
||||
val tcString = TCFStringEncoder.encode(state, makeSampleConfig())!!
|
||||
assertFalse("TC string must not contain '/'", tcString.contains('/'))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encode produces valid Base64url without padding`() {
|
||||
val state = ConsentState(visitorId = "v1").acceptingAll()
|
||||
val tcString = TCFStringEncoder.encode(state, makeSampleConfig())!!
|
||||
assertFalse("TC string must not contain '='", tcString.contains('='))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encode produces decodable Base64`() {
|
||||
val state = ConsentState(visitorId = "v1").acceptingAll()
|
||||
val tcString = TCFStringEncoder.encode(state, makeSampleConfig())!!
|
||||
|
||||
// Convert Base64url back to standard Base64 with padding for decoding.
|
||||
var standard = tcString
|
||||
.replace('-', '+')
|
||||
.replace('_', '/')
|
||||
val remainder = standard.length % 4
|
||||
if (remainder != 0) standard += "=".repeat(4 - remainder)
|
||||
|
||||
val data = Base64.getDecoder().decode(standard)
|
||||
assertNotNull(data)
|
||||
assertTrue(data.isNotEmpty())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Determinism
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `encode produces identical output for identical input`() {
|
||||
val ts = 1_700_000_000_000L
|
||||
val state = ConsentState(
|
||||
visitorId = "v1",
|
||||
accepted = setOf(ConsentCategory.ANALYTICS, ConsentCategory.MARKETING),
|
||||
rejected = setOf(ConsentCategory.FUNCTIONAL, ConsentCategory.PERSONALISATION),
|
||||
consentedAtMs = ts,
|
||||
bannerVersion = "v1",
|
||||
)
|
||||
val config = makeSampleConfig()
|
||||
|
||||
val result1 = TCFStringEncoder.encode(state, config)
|
||||
val result2 = TCFStringEncoder.encode(state, config)
|
||||
|
||||
assertEquals(result1, result2)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Different states produce different strings
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `encode produces distinct strings for acceptAll vs rejectAll`() {
|
||||
val ts = 1_700_000_000_000L
|
||||
val config = makeSampleConfig()
|
||||
|
||||
val acceptedState = ConsentState(
|
||||
visitorId = "v1",
|
||||
accepted = ConsentCategory.entries.filter { it.requiresConsent }.toSet(),
|
||||
consentedAtMs = ts,
|
||||
)
|
||||
val rejectedState = ConsentState(
|
||||
visitorId = "v1",
|
||||
rejected = ConsentCategory.entries.filter { it.requiresConsent }.toSet(),
|
||||
consentedAtMs = ts,
|
||||
)
|
||||
|
||||
val tcAccepted = TCFStringEncoder.encode(acceptedState, config)
|
||||
val tcRejected = TCFStringEncoder.encode(rejectedState, config)
|
||||
|
||||
assertNotEquals(tcAccepted, tcRejected)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Minimum length
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `encode produces string of reasonable length`() {
|
||||
val state = ConsentState(visitorId = "v1").acceptingAll()
|
||||
val tcString = TCFStringEncoder.encode(state, makeSampleConfig())!!
|
||||
|
||||
// A valid core TC string serialises to at least ~20 bytes before Base64url encoding.
|
||||
// 28 chars is a conservative lower bound.
|
||||
assertTrue("TC string appears too short: ${tcString.length}", tcString.length > 28)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun makeSampleConfig() = ConsentConfig(
|
||||
siteId = "site-001",
|
||||
siteName = "Test Site",
|
||||
blockingMode = ConsentConfig.BlockingMode.OPT_IN,
|
||||
consentExpiryDays = 365,
|
||||
bannerVersion = "v1",
|
||||
bannerConfig = ConsentConfig.BannerConfig(),
|
||||
categories = emptyList(),
|
||||
)
|
||||
}
|
||||
39
sdks/ios/ConsentOS/ConsentOS.podspec
Normal file
39
sdks/ios/ConsentOS/ConsentOS.podspec
Normal file
@@ -0,0 +1,39 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ConsentOS'
|
||||
s.version = '0.1.0'
|
||||
s.summary = 'iOS consent management SDK for ConsentOS.'
|
||||
s.description = <<~DESC
|
||||
ConsentOS provides cookie and tracking consent management for iOS apps.
|
||||
It handles consent collection, persistence, server synchronisation,
|
||||
IAB TCF v2.2 string generation, and Google Consent Mode v2 signalling.
|
||||
DESC
|
||||
|
||||
s.homepage = 'https://consentos.dev'
|
||||
s.license = { :type => 'Elastic-2.0', :file => 'LICENSE' }
|
||||
s.author = { 'ConsentOS' => 'hello@consentos.dev' }
|
||||
|
||||
s.ios.deployment_target = '15.0'
|
||||
s.swift_version = '5.9'
|
||||
|
||||
s.source = {
|
||||
:git => 'https://github.com/consentos/consentos.git',
|
||||
:tag => "ios-sdk/#{s.version}"
|
||||
}
|
||||
|
||||
# Core module — no external dependencies
|
||||
s.subspec 'Core' do |core|
|
||||
core.source_files = 'sdks/ios/ConsentOS/Sources/ConsentOSCore/**/*.swift'
|
||||
end
|
||||
|
||||
# UI module — depends on Core, SwiftUI built-in
|
||||
s.subspec 'UI' do |ui|
|
||||
ui.source_files = 'sdks/ios/ConsentOS/Sources/ConsentOSUI/**/*.swift'
|
||||
ui.dependency 'ConsentOS/Core'
|
||||
ui.frameworks = 'SwiftUI', 'UIKit'
|
||||
end
|
||||
|
||||
# Default subspecs
|
||||
s.default_subspec = 'UI'
|
||||
|
||||
s.frameworks = 'Foundation'
|
||||
end
|
||||
38
sdks/ios/ConsentOS/Package.swift
Normal file
38
sdks/ios/ConsentOS/Package.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
// swift-tools-version: 5.9
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "ConsentOS",
|
||||
platforms: [
|
||||
.iOS(.v15),
|
||||
.macOS(.v12) // macOS target for running tests via `swift test`
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "ConsentOSCore",
|
||||
targets: ["ConsentOSCore"]
|
||||
),
|
||||
.library(
|
||||
name: "ConsentOSUI",
|
||||
targets: ["ConsentOSUI"]
|
||||
)
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "ConsentOSCore",
|
||||
path: "Sources/ConsentOSCore"
|
||||
),
|
||||
.target(
|
||||
name: "ConsentOSUI",
|
||||
dependencies: ["ConsentOSCore"],
|
||||
path: "Sources/ConsentOSUI"
|
||||
),
|
||||
.testTarget(
|
||||
name: "ConsentOSCoreTests",
|
||||
dependencies: ["ConsentOSCore"],
|
||||
path: "Tests/ConsentOSCoreTests"
|
||||
)
|
||||
]
|
||||
)
|
||||
165
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentAPI.swift
Normal file
165
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentAPI.swift
Normal file
@@ -0,0 +1,165 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Protocol
|
||||
|
||||
/// Abstracts the network layer for testability.
|
||||
public protocol ConsentAPIProtocol: Sendable {
|
||||
/// Fetches the effective site configuration from the API.
|
||||
func fetchConfig(siteId: String) async throws -> ConsentConfig
|
||||
|
||||
/// Posts a consent record to the server.
|
||||
func postConsent(_ payload: ConsentPayload) async throws
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
/// Errors that can be thrown by the CMP API client.
|
||||
public enum ConsentAPIError: Error, LocalizedError {
|
||||
case invalidURL
|
||||
case unexpectedStatusCode(Int)
|
||||
case decodingFailure(Error)
|
||||
case networkFailure(Error)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return "The constructed API URL is invalid."
|
||||
case .unexpectedStatusCode(let code):
|
||||
return "The server returned an unexpected HTTP status code: \(code)."
|
||||
case .decodingFailure(let underlying):
|
||||
return "Failed to decode the server response: \(underlying.localizedDescription)"
|
||||
case .networkFailure(let underlying):
|
||||
return "A network error occurred: \(underlying.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Consent Payload
|
||||
|
||||
/// The request body sent when recording a consent event.
|
||||
public struct ConsentPayload: Codable, Sendable {
|
||||
public let siteId: String
|
||||
public let visitorId: String
|
||||
public let platform: String // Always "ios"
|
||||
public let accepted: [String] // ConsentCategory raw values
|
||||
public let rejected: [String]
|
||||
public let consentedAt: Date
|
||||
public let bannerVersion: String?
|
||||
public let userAgent: String?
|
||||
public let tcString: String?
|
||||
|
||||
public init(
|
||||
siteId: String,
|
||||
visitorId: String,
|
||||
accepted: [ConsentCategory],
|
||||
rejected: [ConsentCategory],
|
||||
consentedAt: Date,
|
||||
bannerVersion: String?,
|
||||
userAgent: String? = nil,
|
||||
tcString: String? = nil
|
||||
) {
|
||||
self.siteId = siteId
|
||||
self.visitorId = visitorId
|
||||
self.platform = "ios"
|
||||
self.accepted = accepted.map(\.rawValue)
|
||||
self.rejected = rejected.map(\.rawValue)
|
||||
self.consentedAt = consentedAt
|
||||
self.bannerVersion = bannerVersion
|
||||
self.userAgent = userAgent
|
||||
self.tcString = tcString
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live Implementation
|
||||
|
||||
/// URLSession-backed API client that communicates with the CMP API.
|
||||
public final class ConsentAPI: ConsentAPIProtocol, @unchecked Sendable {
|
||||
|
||||
private let apiBase: URL
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
private let encoder: JSONEncoder
|
||||
|
||||
// MARK: - Initialiser
|
||||
|
||||
/// - Parameters:
|
||||
/// - apiBase: The base URL of the CMP API (e.g. `https://api.example.com`).
|
||||
/// - session: Defaults to `URLSession.shared`; inject a custom session in tests.
|
||||
public init(apiBase: URL, session: URLSession = .shared) {
|
||||
self.apiBase = apiBase
|
||||
self.session = session
|
||||
|
||||
let dec = JSONDecoder()
|
||||
dec.dateDecodingStrategy = .iso8601
|
||||
dec.keyDecodingStrategy = .convertFromSnakeCase
|
||||
self.decoder = dec
|
||||
|
||||
let enc = JSONEncoder()
|
||||
enc.dateEncodingStrategy = .iso8601
|
||||
enc.keyEncodingStrategy = .convertToSnakeCase
|
||||
self.encoder = enc
|
||||
}
|
||||
|
||||
// MARK: - ConsentAPIProtocol
|
||||
|
||||
public func fetchConfig(siteId: String) async throws -> ConsentConfig {
|
||||
let url = apiBase
|
||||
.appendingPathComponent("api/v1/config/sites")
|
||||
.appendingPathComponent(siteId)
|
||||
.appendingPathComponent("effective")
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue(sdkUserAgent, forHTTPHeaderField: "User-Agent")
|
||||
|
||||
let (data, response) = try await performRequest(request)
|
||||
try validateResponse(response)
|
||||
|
||||
do {
|
||||
return try decoder.decode(ConsentConfig.self, from: data)
|
||||
} catch {
|
||||
throw ConsentAPIError.decodingFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
public func postConsent(_ payload: ConsentPayload) async throws {
|
||||
let url = apiBase.appendingPathComponent("api/v1/consent")
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue(sdkUserAgent, forHTTPHeaderField: "User-Agent")
|
||||
|
||||
do {
|
||||
request.httpBody = try encoder.encode(payload)
|
||||
} catch {
|
||||
throw ConsentAPIError.decodingFailure(error)
|
||||
}
|
||||
|
||||
let (_, response) = try await performRequest(request)
|
||||
try validateResponse(response)
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func performRequest(_ request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
do {
|
||||
return try await session.data(for: request)
|
||||
} catch {
|
||||
throw ConsentAPIError.networkFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func validateResponse(_ response: URLResponse) throws {
|
||||
guard let httpResponse = response as? HTTPURLResponse else { return }
|
||||
guard (200..<300).contains(httpResponse.statusCode) else {
|
||||
throw ConsentAPIError.unexpectedStatusCode(httpResponse.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
private var sdkUserAgent: String {
|
||||
"ConsentOS-iOS/1.0.0 (Swift)"
|
||||
}
|
||||
}
|
||||
101
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentCategory.swift
Normal file
101
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentCategory.swift
Normal file
@@ -0,0 +1,101 @@
|
||||
/// Consent categories matching the platform's taxonomy.
|
||||
///
|
||||
/// Each category maps to IAB TCF v2.2 purposes and Google Consent Mode consent types.
|
||||
/// The `necessary` category is always granted and cannot be revoked by the user.
|
||||
public enum ConsentCategory: String, CaseIterable, Codable, Sendable {
|
||||
/// Strictly necessary cookies — always allowed, no consent required.
|
||||
case necessary
|
||||
|
||||
/// Functional / preference cookies (e.g. language, saved settings).
|
||||
case functional
|
||||
|
||||
/// Analytics / statistics cookies (e.g. page views, session data).
|
||||
case analytics
|
||||
|
||||
/// Marketing / advertising cookies (e.g. retargeting, personalised ads).
|
||||
case marketing
|
||||
|
||||
/// Personalisation cookies (e.g. content recommendations).
|
||||
case personalisation
|
||||
|
||||
// MARK: - TCF Mappings
|
||||
|
||||
/// IAB TCF v2.2 purpose IDs associated with this category.
|
||||
///
|
||||
/// Returns an empty array for `necessary`, which does not require consent purposes.
|
||||
public var tcfPurposeIds: [Int] {
|
||||
switch self {
|
||||
case .necessary:
|
||||
return []
|
||||
case .functional:
|
||||
return [1] // Store and/or access information on a device
|
||||
case .analytics:
|
||||
return [7, 8, 9, 10] // Measurement, market research, product development
|
||||
case .marketing:
|
||||
return [2, 3, 4] // Select basic/personalised ads, create ad profile
|
||||
case .personalisation:
|
||||
return [5, 6] // Create content profile, select personalised content
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Google Consent Mode Mappings
|
||||
|
||||
/// Google Consent Mode v2 consent type string for this category.
|
||||
///
|
||||
/// Returns `nil` for categories that do not have a direct GCM mapping.
|
||||
public var gcmConsentType: String? {
|
||||
switch self {
|
||||
case .necessary:
|
||||
return nil
|
||||
case .functional:
|
||||
return "functionality_storage"
|
||||
case .analytics:
|
||||
return "analytics_storage"
|
||||
case .marketing:
|
||||
return "ad_storage"
|
||||
case .personalisation:
|
||||
return "personalization_storage"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Display
|
||||
|
||||
/// Human-readable display name for the category (British English).
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .necessary:
|
||||
return "Strictly Necessary"
|
||||
case .functional:
|
||||
return "Functional"
|
||||
case .analytics:
|
||||
return "Analytics"
|
||||
case .marketing:
|
||||
return "Marketing"
|
||||
case .personalisation:
|
||||
return "Personalisation"
|
||||
}
|
||||
}
|
||||
|
||||
/// Brief description of the category for banner display.
|
||||
public var displayDescription: String {
|
||||
switch self {
|
||||
case .necessary:
|
||||
return "Essential for the website to function. These cannot be disabled."
|
||||
case .functional:
|
||||
return "Enable enhanced functionality such as remembering your preferences."
|
||||
case .analytics:
|
||||
return "Help us understand how visitors interact with the website."
|
||||
case .marketing:
|
||||
return "Used to deliver relevant advertisements and track ad campaign performance."
|
||||
case .personalisation:
|
||||
return "Allow us to personalise content based on your interests."
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether consent for this category is required before storing data.
|
||||
///
|
||||
/// `necessary` is exempt from consent requirements under ePrivacy regulations.
|
||||
public var requiresConsent: Bool {
|
||||
self != .necessary
|
||||
}
|
||||
}
|
||||
188
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentConfig.swift
Normal file
188
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentConfig.swift
Normal file
@@ -0,0 +1,188 @@
|
||||
import Foundation
|
||||
|
||||
/// The effective site configuration loaded from the CMP API.
|
||||
///
|
||||
/// Maps to the response of `GET {apiBase}/api/v1/config/sites/{siteId}/effective`.
|
||||
/// The configuration drives banner display, theming, blocking mode, and consent expiry.
|
||||
public struct ConsentConfig: Codable, Sendable {
|
||||
|
||||
// MARK: - Top-level Fields
|
||||
|
||||
/// Unique identifier for the site.
|
||||
public let siteId: String
|
||||
|
||||
/// Human-readable name for the site.
|
||||
public let siteName: String
|
||||
|
||||
/// The blocking mode determining the default consent model.
|
||||
public let blockingMode: BlockingMode
|
||||
|
||||
/// Consent validity in days. After expiry the banner is shown again.
|
||||
public let consentExpiryDays: Int
|
||||
|
||||
/// The current version of this configuration.
|
||||
/// Stored alongside consent records to detect when re-consent is needed.
|
||||
public let bannerVersion: String
|
||||
|
||||
/// Banner display and theming configuration.
|
||||
public let bannerConfig: BannerConfig
|
||||
|
||||
/// Available categories for this site.
|
||||
public let categories: [CategoryConfig]
|
||||
|
||||
// MARK: - Initialiser
|
||||
|
||||
public init(
|
||||
siteId: String,
|
||||
siteName: String,
|
||||
blockingMode: BlockingMode,
|
||||
consentExpiryDays: Int,
|
||||
bannerVersion: String,
|
||||
bannerConfig: BannerConfig,
|
||||
categories: [CategoryConfig]
|
||||
) {
|
||||
self.siteId = siteId
|
||||
self.siteName = siteName
|
||||
self.blockingMode = blockingMode
|
||||
self.consentExpiryDays = consentExpiryDays
|
||||
self.bannerVersion = bannerVersion
|
||||
self.bannerConfig = bannerConfig
|
||||
self.categories = categories
|
||||
}
|
||||
|
||||
// MARK: - Blocking Mode
|
||||
|
||||
/// The consent model applied to visitors.
|
||||
public enum BlockingMode: String, Codable, Sendable {
|
||||
/// User must opt in before non-essential scripts run (GDPR default).
|
||||
case optIn = "opt_in"
|
||||
/// Non-essential scripts run by default; user may opt out (CCPA default).
|
||||
case optOut = "opt_out"
|
||||
/// Informational notice only; no blocking.
|
||||
case informational
|
||||
}
|
||||
|
||||
// MARK: - Banner Configuration
|
||||
|
||||
/// Visual and behavioural configuration for the consent banner.
|
||||
public struct BannerConfig: Codable, Sendable {
|
||||
/// Display mode controlling the banner layout.
|
||||
public let displayMode: DisplayMode
|
||||
|
||||
/// Primary background colour as a hex string (e.g. `"#FFFFFF"`).
|
||||
public let backgroundColor: String?
|
||||
|
||||
/// Primary text colour as a hex string.
|
||||
public let textColor: String?
|
||||
|
||||
/// Accent colour used for buttons and highlights.
|
||||
public let accentColor: String?
|
||||
|
||||
/// Text for the "Accept all" button.
|
||||
public let acceptButtonText: String?
|
||||
|
||||
/// Text for the "Reject all" button.
|
||||
public let rejectButtonText: String?
|
||||
|
||||
/// Text for the "Manage preferences" button.
|
||||
public let manageButtonText: String?
|
||||
|
||||
/// The banner title text.
|
||||
public let title: String?
|
||||
|
||||
/// The banner body copy.
|
||||
public let description: String?
|
||||
|
||||
/// URL for the site's privacy policy.
|
||||
public let privacyPolicyUrl: String?
|
||||
|
||||
public enum DisplayMode: String, Codable, Sendable {
|
||||
case overlay
|
||||
case bottomBanner = "bottom_banner"
|
||||
case topBanner = "top_banner"
|
||||
case cornerPopup = "corner_popup"
|
||||
case inline
|
||||
}
|
||||
|
||||
// Default-value initialiser used in tests and previews
|
||||
public init(
|
||||
displayMode: DisplayMode = .bottomBanner,
|
||||
backgroundColor: String? = nil,
|
||||
textColor: String? = nil,
|
||||
accentColor: String? = nil,
|
||||
acceptButtonText: String? = nil,
|
||||
rejectButtonText: String? = nil,
|
||||
manageButtonText: String? = nil,
|
||||
title: String? = nil,
|
||||
description: String? = nil,
|
||||
privacyPolicyUrl: String? = nil
|
||||
) {
|
||||
self.displayMode = displayMode
|
||||
self.backgroundColor = backgroundColor
|
||||
self.textColor = textColor
|
||||
self.accentColor = accentColor
|
||||
self.acceptButtonText = acceptButtonText
|
||||
self.rejectButtonText = rejectButtonText
|
||||
self.manageButtonText = manageButtonText
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.privacyPolicyUrl = privacyPolicyUrl
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Configuration
|
||||
|
||||
/// Per-category configuration as returned by the API.
|
||||
public struct CategoryConfig: Codable, Sendable {
|
||||
/// Machine-readable category key (matches ``ConsentCategory`` raw values).
|
||||
public let key: String
|
||||
/// Whether this category is enabled for this site.
|
||||
public let enabled: Bool
|
||||
/// Overridden display name (falls back to ``ConsentCategory/displayName`` if absent).
|
||||
public let displayName: String?
|
||||
/// Overridden description text.
|
||||
public let description: String?
|
||||
|
||||
public init(key: String, enabled: Bool, displayName: String?, description: String?) {
|
||||
self.key = key
|
||||
self.enabled = enabled
|
||||
self.displayName = displayName
|
||||
self.description = description
|
||||
}
|
||||
|
||||
/// Resolves to the matching ``ConsentCategory``, if the key is recognised.
|
||||
public var category: ConsentCategory? {
|
||||
ConsentCategory(rawValue: key)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
/// Returns only the enabled ``ConsentCategory`` values for this config.
|
||||
public var enabledCategories: [ConsentCategory] {
|
||||
categories.compactMap { cfg in
|
||||
guard cfg.enabled else { return nil }
|
||||
return cfg.category
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cached Config Wrapper
|
||||
|
||||
/// Wraps a ``ConsentConfig`` with metadata needed for cache invalidation.
|
||||
public struct CachedConfig: Codable {
|
||||
public let config: ConsentConfig
|
||||
public let fetchedAt: Date
|
||||
|
||||
/// The cache TTL in seconds (10 minutes by default).
|
||||
public static let ttl: TimeInterval = 600
|
||||
|
||||
public var isExpired: Bool {
|
||||
Date().timeIntervalSince(fetchedAt) > CachedConfig.ttl
|
||||
}
|
||||
|
||||
public init(config: ConsentConfig, fetchedAt: Date) {
|
||||
self.config = config
|
||||
self.fetchedAt = fetchedAt
|
||||
}
|
||||
}
|
||||
306
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentOS.swift
Normal file
306
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentOS.swift
Normal file
@@ -0,0 +1,306 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Delegate Protocol
|
||||
|
||||
/// Receive notifications when the user's consent choices change.
|
||||
public protocol ConsentOSDelegate: AnyObject {
|
||||
/// Called on the main thread after consent has been updated.
|
||||
///
|
||||
/// - Parameter state: The new consent state.
|
||||
func consentDidChange(_ state: ConsentState)
|
||||
}
|
||||
|
||||
// MARK: - ConsentOS
|
||||
|
||||
/// The main entry point for the CMP iOS SDK.
|
||||
///
|
||||
/// Use the shared singleton to configure the SDK, display the consent banner,
|
||||
/// and query consent status. All public methods are safe to call from any thread.
|
||||
///
|
||||
/// ```swift
|
||||
/// // In AppDelegate or @main App
|
||||
/// ConsentOS.shared.configure(siteId: "my-site-id", apiBase: apiURL)
|
||||
///
|
||||
/// // Optionally register a delegate
|
||||
/// ConsentOS.shared.delegate = self
|
||||
///
|
||||
/// // Show banner if needed
|
||||
/// if await ConsentOS.shared.shouldShowBanner() {
|
||||
/// await ConsentOS.shared.showBanner(on: rootViewController)
|
||||
/// }
|
||||
/// ```
|
||||
public final class ConsentOS: @unchecked Sendable {
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
/// The shared SDK instance. Configure this before use.
|
||||
public static let shared = ConsentOS()
|
||||
|
||||
// MARK: - State
|
||||
|
||||
/// The site ID set during ``configure(siteId:apiBase:)``.
|
||||
private(set) public var siteId: String?
|
||||
|
||||
/// Whether the SDK has been configured.
|
||||
public var isConfigured: Bool { siteId != nil }
|
||||
|
||||
/// The current consent state. `nil` until storage has been read on first access.
|
||||
private(set) public var consentState: ConsentState?
|
||||
|
||||
/// The currently loaded site configuration. `nil` until fetched.
|
||||
private(set) public var siteConfig: ConsentConfig?
|
||||
|
||||
/// Delegate notified on consent changes. Weakly held.
|
||||
public weak var delegate: (any ConsentOSDelegate)?
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private var api: (any ConsentAPIProtocol)?
|
||||
private var storage: any ConsentStorageProtocol
|
||||
private var gcmBridge: GCMBridge
|
||||
private let stateLock = NSLock()
|
||||
|
||||
// MARK: - Initialiser
|
||||
|
||||
/// Creates a new instance with the default storage and no-op GCM provider.
|
||||
/// Inject custom dependencies for testing via ``init(storage:api:gcmBridge:)``.
|
||||
public init(
|
||||
storage: any ConsentStorageProtocol = ConsentStorage(),
|
||||
api: (any ConsentAPIProtocol)? = nil,
|
||||
gcmBridge: GCMBridge = GCMBridge()
|
||||
) {
|
||||
self.storage = storage
|
||||
self.api = api
|
||||
self.gcmBridge = gcmBridge
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Configures the SDK with a site ID and API base URL.
|
||||
///
|
||||
/// Must be called before any other method. Loads the persisted consent state
|
||||
/// and fetches the site configuration in the background.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - siteId: The unique identifier for the site (from the CMP dashboard).
|
||||
/// - apiBase: Base URL of the CMP API (e.g. `https://api.example.com`).
|
||||
/// - gcmProvider: Optional GCM analytics provider. Defaults to no-op.
|
||||
public func configure(
|
||||
siteId: String,
|
||||
apiBase: URL,
|
||||
gcmProvider: (any GCMAnalyticsProvider)? = nil
|
||||
) {
|
||||
stateLock.lock()
|
||||
self.siteId = siteId
|
||||
if self.api == nil {
|
||||
self.api = ConsentAPI(apiBase: apiBase)
|
||||
}
|
||||
if let provider = gcmProvider {
|
||||
self.gcmBridge = GCMBridge(provider: provider)
|
||||
}
|
||||
|
||||
// Restore persisted state
|
||||
let visitorId = storage.visitorId()
|
||||
self.consentState = storage.loadState() ?? ConsentState(visitorId: visitorId)
|
||||
stateLock.unlock()
|
||||
|
||||
// Fetch config in the background; apply GCM defaults once available
|
||||
Task {
|
||||
await refreshConfigIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
/// Attaches a custom API client (useful for testing or custom transports).
|
||||
public func setAPI(_ api: any ConsentAPIProtocol) {
|
||||
stateLock.lock()
|
||||
self.api = api
|
||||
stateLock.unlock()
|
||||
}
|
||||
|
||||
// MARK: - Banner Display
|
||||
|
||||
/// Returns `true` if the consent banner should be shown to this visitor.
|
||||
///
|
||||
/// The banner is required when:
|
||||
/// - The user has not yet interacted (no `consentedAt`), or
|
||||
/// - The stored banner version differs from the current config version (re-consent needed), or
|
||||
/// - The stored consent is older than the site's configured expiry.
|
||||
public func shouldShowBanner() async -> Bool {
|
||||
await refreshConfigIfNeeded()
|
||||
|
||||
stateLock.lock()
|
||||
let state = consentState
|
||||
let config = siteConfig
|
||||
stateLock.unlock()
|
||||
|
||||
guard let state else { return true }
|
||||
guard state.hasInteracted else { return true }
|
||||
|
||||
if let config, let consentedAt = state.consentedAt {
|
||||
// Check banner version mismatch
|
||||
if state.bannerVersion != config.bannerVersion {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check consent expiry
|
||||
let expiryInterval = TimeInterval(config.consentExpiryDays * 86_400)
|
||||
if Date().timeIntervalSince(consentedAt) > expiryInterval {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Consent Actions
|
||||
|
||||
/// Accepts all non-necessary consent categories.
|
||||
///
|
||||
/// Updates local state, syncs to the server, and fires GCM signals.
|
||||
public func acceptAll() async {
|
||||
await applyConsent { $0.acceptingAll() }
|
||||
}
|
||||
|
||||
/// Rejects all non-necessary consent categories.
|
||||
public func rejectAll() async {
|
||||
await applyConsent { $0.rejectingAll() }
|
||||
}
|
||||
|
||||
/// Accepts only the specified categories (and rejects all others).
|
||||
///
|
||||
/// - Parameter categories: The set of categories to accept.
|
||||
public func acceptCategories(_ categories: Set<ConsentCategory>) async {
|
||||
await applyConsent { $0.accepting(categories: categories) }
|
||||
}
|
||||
|
||||
// MARK: - Query
|
||||
|
||||
/// Returns the consent status for a specific category.
|
||||
///
|
||||
/// - Returns: `true` if consent has been granted, `false` if denied or not yet given.
|
||||
public func getConsentStatus(for category: ConsentCategory) -> Bool {
|
||||
stateLock.lock()
|
||||
defer { stateLock.unlock() }
|
||||
return consentState?.isGranted(category) ?? (category == .necessary)
|
||||
}
|
||||
|
||||
// MARK: - User Identity
|
||||
|
||||
/// Associates the current visitor with a verified user identity.
|
||||
///
|
||||
/// The JWT is sent alongside subsequent consent records for server-side
|
||||
/// correlation with authenticated users.
|
||||
///
|
||||
/// - Parameter jwt: A signed JWT issued by the host application's auth system.
|
||||
public func identifyUser(jwt: String) {
|
||||
// Store JWT for inclusion in future consent payloads.
|
||||
// In a production implementation this would also re-sync the consent record.
|
||||
UserDefaults.standard.set(jwt, forKey: "com.cmp.consent.userJwt")
|
||||
}
|
||||
|
||||
// MARK: - Internal: Config Refresh
|
||||
|
||||
@discardableResult
|
||||
func refreshConfigIfNeeded() async -> ConsentConfig? {
|
||||
// Check cached config first
|
||||
if let cached = storage.loadCachedConfig(), !cached.isExpired {
|
||||
stateLock.lock()
|
||||
self.siteConfig = cached.config
|
||||
stateLock.unlock()
|
||||
return cached.config
|
||||
}
|
||||
|
||||
guard let siteId, let api else { return nil }
|
||||
|
||||
do {
|
||||
let config = try await api.fetchConfig(siteId: siteId)
|
||||
let cached = CachedConfig(config: config, fetchedAt: Date())
|
||||
storage.saveCachedConfig(cached)
|
||||
|
||||
stateLock.lock()
|
||||
self.siteConfig = config
|
||||
// Stamp the banner version onto the current state
|
||||
if var state = self.consentState {
|
||||
state.bannerVersion = config.bannerVersion
|
||||
self.consentState = state
|
||||
}
|
||||
stateLock.unlock()
|
||||
|
||||
// Apply GCM defaults for new visitors
|
||||
gcmBridge.applyDefaults(config: config)
|
||||
return config
|
||||
} catch {
|
||||
// Non-fatal — the SDK can operate with cached or default config
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internal: Apply Consent
|
||||
|
||||
private func applyConsent(transform: (ConsentState) -> ConsentState) async {
|
||||
stateLock.lock()
|
||||
guard let current = consentState else {
|
||||
stateLock.unlock()
|
||||
return
|
||||
}
|
||||
var newState = transform(current)
|
||||
// Stamp banner version
|
||||
newState = ConsentState(
|
||||
visitorId: newState.visitorId,
|
||||
accepted: newState.accepted,
|
||||
rejected: newState.rejected,
|
||||
consentedAt: newState.consentedAt,
|
||||
bannerVersion: siteConfig?.bannerVersion ?? current.bannerVersion
|
||||
)
|
||||
self.consentState = newState
|
||||
stateLock.unlock()
|
||||
|
||||
// Persist locally
|
||||
storage.saveState(newState)
|
||||
|
||||
// Signal GCM
|
||||
gcmBridge.applyConsent(state: newState)
|
||||
|
||||
// Generate TC string
|
||||
let tcString: String?
|
||||
if let config = siteConfig {
|
||||
tcString = TCFStringEncoder.encode(state: newState, config: config)
|
||||
} else {
|
||||
tcString = nil
|
||||
}
|
||||
|
||||
// Sync to server (best-effort; failures are non-fatal)
|
||||
await syncConsent(state: newState, tcString: tcString)
|
||||
|
||||
// Notify delegate on main thread
|
||||
let delegateState = newState
|
||||
await MainActor.run {
|
||||
delegate?.consentDidChange(delegateState)
|
||||
}
|
||||
}
|
||||
|
||||
private func syncConsent(state: ConsentState, tcString: String?) async {
|
||||
guard let siteId, let api, let consentedAt = state.consentedAt else { return }
|
||||
|
||||
let payload = ConsentPayload(
|
||||
siteId: siteId,
|
||||
visitorId: state.visitorId,
|
||||
accepted: Array(state.accepted),
|
||||
rejected: Array(state.rejected),
|
||||
consentedAt: consentedAt,
|
||||
bannerVersion: state.bannerVersion,
|
||||
tcString: tcString
|
||||
)
|
||||
|
||||
do {
|
||||
try await api.postConsent(payload)
|
||||
} catch {
|
||||
// Consent is stored locally; server sync failure is non-fatal.
|
||||
// In production, implement a retry queue here.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: `showBanner(on:)` for UIKit is provided by the ConsentOSUI module.
|
||||
// Import ConsentOSUI and call `ConsentOS.shared.showBanner(on:)` after adding
|
||||
// that module as a dependency.
|
||||
118
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentState.swift
Normal file
118
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentState.swift
Normal file
@@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
|
||||
/// Represents the complete consent state for a visitor.
|
||||
///
|
||||
/// This model mirrors the web consent cookie structure for cross-platform consistency.
|
||||
/// It is persisted locally via ``ConsentStorage`` and synced to the server.
|
||||
public struct ConsentState: Codable, Equatable, Sendable {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// A stable, anonymous identifier for this device/visitor.
|
||||
/// Generated once and persisted across sessions.
|
||||
public let visitorId: String
|
||||
|
||||
/// The set of categories the visitor has explicitly accepted.
|
||||
public var accepted: Set<ConsentCategory>
|
||||
|
||||
/// The set of categories the visitor has explicitly rejected.
|
||||
public var rejected: Set<ConsentCategory>
|
||||
|
||||
/// The timestamp at which consent was last recorded.
|
||||
public var consentedAt: Date?
|
||||
|
||||
/// The banner configuration version active when consent was collected.
|
||||
/// Used to detect when consent must be re-collected after a config change.
|
||||
public var bannerVersion: String?
|
||||
|
||||
/// Whether the user has interacted with the banner (accepted or rejected).
|
||||
///
|
||||
/// Returns `false` when the state represents the pre-consent default.
|
||||
public var hasInteracted: Bool {
|
||||
consentedAt != nil
|
||||
}
|
||||
|
||||
// MARK: - Derived State
|
||||
|
||||
/// Returns `true` if the user has granted consent for the given category.
|
||||
///
|
||||
/// `necessary` is always considered granted regardless of the stored state.
|
||||
public func isGranted(_ category: ConsentCategory) -> Bool {
|
||||
guard category != .necessary else { return true }
|
||||
return accepted.contains(category)
|
||||
}
|
||||
|
||||
/// Returns `true` if the user has explicitly denied consent for the given category.
|
||||
public func isDenied(_ category: ConsentCategory) -> Bool {
|
||||
guard category != .necessary else { return false }
|
||||
return rejected.contains(category)
|
||||
}
|
||||
|
||||
// MARK: - Initialisers
|
||||
|
||||
/// Creates a new, blank consent state for the given visitor.
|
||||
///
|
||||
/// No categories are accepted or rejected; `consentedAt` is `nil`.
|
||||
public init(visitorId: String) {
|
||||
self.visitorId = visitorId
|
||||
self.accepted = []
|
||||
self.rejected = []
|
||||
self.consentedAt = nil
|
||||
self.bannerVersion = nil
|
||||
}
|
||||
|
||||
/// Creates a fully populated consent state.
|
||||
public init(
|
||||
visitorId: String,
|
||||
accepted: Set<ConsentCategory>,
|
||||
rejected: Set<ConsentCategory>,
|
||||
consentedAt: Date?,
|
||||
bannerVersion: String?
|
||||
) {
|
||||
self.visitorId = visitorId
|
||||
self.accepted = accepted
|
||||
self.rejected = rejected
|
||||
self.consentedAt = consentedAt
|
||||
self.bannerVersion = bannerVersion
|
||||
}
|
||||
|
||||
// MARK: - Mutations
|
||||
|
||||
/// Returns a new state with all non-necessary categories accepted.
|
||||
public func acceptingAll() -> ConsentState {
|
||||
let allOptional = ConsentCategory.allCases.filter { $0.requiresConsent }
|
||||
return ConsentState(
|
||||
visitorId: visitorId,
|
||||
accepted: Set(allOptional),
|
||||
rejected: [],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: bannerVersion
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns a new state with all non-necessary categories rejected.
|
||||
public func rejectingAll() -> ConsentState {
|
||||
let allOptional = ConsentCategory.allCases.filter { $0.requiresConsent }
|
||||
return ConsentState(
|
||||
visitorId: visitorId,
|
||||
accepted: [],
|
||||
rejected: Set(allOptional),
|
||||
consentedAt: Date(),
|
||||
bannerVersion: bannerVersion
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns a new state accepting only the specified categories (and rejecting the rest).
|
||||
public func accepting(categories: Set<ConsentCategory>) -> ConsentState {
|
||||
let allOptional = Set(ConsentCategory.allCases.filter { $0.requiresConsent })
|
||||
let toAccept = categories.filter { $0.requiresConsent }
|
||||
let toReject = allOptional.subtracting(toAccept)
|
||||
return ConsentState(
|
||||
visitorId: visitorId,
|
||||
accepted: toAccept,
|
||||
rejected: toReject,
|
||||
consentedAt: Date(),
|
||||
bannerVersion: bannerVersion
|
||||
)
|
||||
}
|
||||
}
|
||||
101
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentStorage.swift
Normal file
101
sdks/ios/ConsentOS/Sources/ConsentOSCore/ConsentStorage.swift
Normal file
@@ -0,0 +1,101 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Protocol
|
||||
|
||||
/// Abstracts local persistence so the storage layer can be swapped in tests.
|
||||
public protocol ConsentStorageProtocol: Sendable {
|
||||
func loadState() -> ConsentState?
|
||||
func saveState(_ state: ConsentState)
|
||||
func clearState()
|
||||
|
||||
func loadCachedConfig() -> CachedConfig?
|
||||
func saveCachedConfig(_ cached: CachedConfig)
|
||||
func clearCachedConfig()
|
||||
|
||||
/// Loads or generates a stable visitor ID.
|
||||
func visitorId() -> String
|
||||
}
|
||||
|
||||
// MARK: - UserDefaults Implementation
|
||||
|
||||
/// Persists consent state and site configuration in `UserDefaults`.
|
||||
///
|
||||
/// All keys are namespaced under `com.cmp.consent` to avoid collisions.
|
||||
public final class ConsentStorage: ConsentStorageProtocol, @unchecked Sendable {
|
||||
|
||||
// MARK: - Keys
|
||||
|
||||
private enum Keys {
|
||||
static let consentState = "com.cmp.consent.state"
|
||||
static let cachedConfig = "com.cmp.consent.config"
|
||||
static let visitorId = "com.cmp.consent.visitorId"
|
||||
}
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
// MARK: - Initialiser
|
||||
|
||||
/// Creates a storage instance backed by the given `UserDefaults` suite.
|
||||
///
|
||||
/// - Parameter suiteName: Pass a custom suite name to isolate storage per app group.
|
||||
/// Defaults to `nil`, which uses `UserDefaults.standard`.
|
||||
public init(suiteName: String? = nil) {
|
||||
self.defaults = UserDefaults(suiteName: suiteName) ?? .standard
|
||||
|
||||
let enc = JSONEncoder()
|
||||
enc.dateEncodingStrategy = .iso8601
|
||||
self.encoder = enc
|
||||
|
||||
let dec = JSONDecoder()
|
||||
dec.dateDecodingStrategy = .iso8601
|
||||
self.decoder = dec
|
||||
}
|
||||
|
||||
// MARK: - ConsentState
|
||||
|
||||
public func loadState() -> ConsentState? {
|
||||
guard let data = defaults.data(forKey: Keys.consentState) else { return nil }
|
||||
return try? decoder.decode(ConsentState.self, from: data)
|
||||
}
|
||||
|
||||
public func saveState(_ state: ConsentState) {
|
||||
guard let data = try? encoder.encode(state) else { return }
|
||||
defaults.set(data, forKey: Keys.consentState)
|
||||
}
|
||||
|
||||
public func clearState() {
|
||||
defaults.removeObject(forKey: Keys.consentState)
|
||||
}
|
||||
|
||||
// MARK: - Cached Config
|
||||
|
||||
public func loadCachedConfig() -> CachedConfig? {
|
||||
guard let data = defaults.data(forKey: Keys.cachedConfig) else { return nil }
|
||||
return try? decoder.decode(CachedConfig.self, from: data)
|
||||
}
|
||||
|
||||
public func saveCachedConfig(_ cached: CachedConfig) {
|
||||
guard let data = try? encoder.encode(cached) else { return }
|
||||
defaults.set(data, forKey: Keys.cachedConfig)
|
||||
}
|
||||
|
||||
public func clearCachedConfig() {
|
||||
defaults.removeObject(forKey: Keys.cachedConfig)
|
||||
}
|
||||
|
||||
// MARK: - Visitor ID
|
||||
|
||||
/// Returns the persisted visitor ID, generating and saving a new UUID if absent.
|
||||
public func visitorId() -> String {
|
||||
if let existing = defaults.string(forKey: Keys.visitorId) {
|
||||
return existing
|
||||
}
|
||||
let newId = UUID().uuidString
|
||||
defaults.set(newId, forKey: Keys.visitorId)
|
||||
return newId
|
||||
}
|
||||
}
|
||||
128
sdks/ios/ConsentOS/Sources/ConsentOSCore/GCMBridge.swift
Normal file
128
sdks/ios/ConsentOS/Sources/ConsentOSCore/GCMBridge.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - GCM Consent Types
|
||||
|
||||
/// The Google Consent Mode v2 consent type strings.
|
||||
public enum GCMConsentType: String, CaseIterable, Sendable {
|
||||
case analyticsStorage = "analytics_storage"
|
||||
case adStorage = "ad_storage"
|
||||
case adUserData = "ad_user_data"
|
||||
case adPersonalisation = "ad_personalization"
|
||||
case functionalityStorage = "functionality_storage"
|
||||
case personalisationStorage = "personalization_storage"
|
||||
case securityStorage = "security_storage"
|
||||
}
|
||||
|
||||
/// The granted/denied status for a GCM consent type.
|
||||
public enum GCMConsentStatus: String, Sendable {
|
||||
case granted = "granted"
|
||||
case denied = "denied"
|
||||
}
|
||||
|
||||
// MARK: - GCM Analytics Provider Protocol
|
||||
|
||||
/// Abstracts the Firebase Analytics / Google Tag Manager call surface.
|
||||
///
|
||||
/// Conforming types should call the underlying GCM API. The default no-op
|
||||
/// implementation is used when Firebase is not present.
|
||||
public protocol GCMAnalyticsProvider: AnyObject, Sendable {
|
||||
/// Sets the default consent state before any interaction.
|
||||
func setConsentDefaults(_ defaults: [String: String])
|
||||
|
||||
/// Updates consent state after the user interacts with the banner.
|
||||
func updateConsent(_ updates: [String: String])
|
||||
}
|
||||
|
||||
// MARK: - No-Op Provider
|
||||
|
||||
/// A no-op ``GCMAnalyticsProvider`` used when Firebase Analytics is not linked.
|
||||
public final class NoOpGCMAnalyticsProvider: GCMAnalyticsProvider, @unchecked Sendable {
|
||||
public init() {}
|
||||
public func setConsentDefaults(_ defaults: [String: String]) {}
|
||||
public func updateConsent(_ updates: [String: String]) {}
|
||||
}
|
||||
|
||||
// MARK: - GCM Bridge
|
||||
|
||||
/// Maps CMP consent state to Google Consent Mode v2 signals.
|
||||
///
|
||||
/// On app launch, call ``applyDefaults(config:)`` before the user interacts.
|
||||
/// After consent is collected, call ``applyConsent(state:)`` to update GCM.
|
||||
///
|
||||
/// To integrate with Firebase Analytics, implement ``GCMAnalyticsProvider`` and
|
||||
/// call the `Firebase.Analytics.setConsent(_:)` API inside it.
|
||||
public final class GCMBridge: @unchecked Sendable {
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let provider: any GCMAnalyticsProvider
|
||||
|
||||
// MARK: - Initialiser
|
||||
|
||||
/// - Parameter provider: The analytics provider to forward consent signals to.
|
||||
/// Defaults to a no-op implementation.
|
||||
public init(provider: any GCMAnalyticsProvider = NoOpGCMAnalyticsProvider()) {
|
||||
self.provider = provider
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Sends default (pre-consent) consent signals to GCM based on the site's blocking mode.
|
||||
///
|
||||
/// Call this as early as possible — ideally before any analytics events are sent.
|
||||
///
|
||||
/// - Parameter config: The effective site configuration.
|
||||
public func applyDefaults(config: ConsentConfig) {
|
||||
let defaults = buildDefaults(for: config.blockingMode)
|
||||
provider.setConsentDefaults(defaults)
|
||||
}
|
||||
|
||||
/// Updates GCM consent signals to reflect the user's explicit choices.
|
||||
///
|
||||
/// - Parameter state: The resolved consent state after user interaction.
|
||||
public func applyConsent(state: ConsentState) {
|
||||
let updates = buildConsentMap(from: state)
|
||||
provider.updateConsent(updates)
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Builds the default GCM consent map based on blocking mode.
|
||||
///
|
||||
/// - `opt_in`: all types denied by default (GDPR).
|
||||
/// - `opt_out`: all types granted by default (CCPA).
|
||||
/// - `informational`: all types granted.
|
||||
private func buildDefaults(for mode: ConsentConfig.BlockingMode) -> [String: String] {
|
||||
let status: GCMConsentStatus = mode == .optIn ? .denied : .granted
|
||||
return Dictionary(
|
||||
uniqueKeysWithValues: GCMConsentType.allCases.map {
|
||||
($0.rawValue, status.rawValue)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Maps the consent state's accepted/rejected categories to GCM consent type values.
|
||||
private func buildConsentMap(from state: ConsentState) -> [String: String] {
|
||||
var map: [String: String] = [:]
|
||||
|
||||
// security_storage is always granted — it is necessary for security.
|
||||
map[GCMConsentType.securityStorage.rawValue] = GCMConsentStatus.granted.rawValue
|
||||
|
||||
for category in ConsentCategory.allCases {
|
||||
guard let gcmType = category.gcmConsentType else { continue }
|
||||
let status: GCMConsentStatus = state.isGranted(category) ? .granted : .denied
|
||||
map[gcmType] = status.rawValue
|
||||
}
|
||||
|
||||
// ad_user_data and ad_personalization follow the marketing category.
|
||||
let marketingGranted = state.isGranted(.marketing)
|
||||
map[GCMConsentType.adUserData.rawValue] = marketingGranted
|
||||
? GCMConsentStatus.granted.rawValue
|
||||
: GCMConsentStatus.denied.rawValue
|
||||
map[GCMConsentType.adPersonalisation.rawValue] = marketingGranted
|
||||
? GCMConsentStatus.granted.rawValue
|
||||
: GCMConsentStatus.denied.rawValue
|
||||
|
||||
return map
|
||||
}
|
||||
}
|
||||
196
sdks/ios/ConsentOS/Sources/ConsentOSCore/TCFStringEncoder.swift
Normal file
196
sdks/ios/ConsentOS/Sources/ConsentOSCore/TCFStringEncoder.swift
Normal file
@@ -0,0 +1,196 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - TCF String Encoder
|
||||
|
||||
/// Encodes a TC string (Transparency & Consent Framework v2.2) from consent state.
|
||||
///
|
||||
/// The TC string is a Base64url-encoded bit field described in the IAB TCF v2.2 specification.
|
||||
/// This implementation encodes the core consent section (segment type 0) sufficient for
|
||||
/// signalling purpose consent to downstream vendors.
|
||||
///
|
||||
/// Reference: https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework
|
||||
public final class TCFStringEncoder: Sendable {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
/// TCF specification version.
|
||||
private static let specVersion: Int = 2
|
||||
|
||||
/// A fixed CMP ID — replace with your registered IAB CMP ID in production.
|
||||
private static let cmpId: Int = 0
|
||||
|
||||
/// CMP SDK version number.
|
||||
private static let cmpVersion: Int = 1
|
||||
|
||||
/// IAB consent language (en).
|
||||
private static let consentLanguage: String = "EN"
|
||||
|
||||
/// Vendor list version. In production, this should be fetched from the GVL.
|
||||
private static let vendorListVersion: Int = 1
|
||||
|
||||
/// Number of TCF purposes defined in the specification.
|
||||
private static let tcfPurposeCount: Int = 24
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Encodes a TC string for the given consent state and site configuration.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - state: The resolved consent state containing accepted/rejected categories.
|
||||
/// - config: The site configuration (used for CMP metadata).
|
||||
/// - Returns: A Base64url-encoded TC string, or `nil` if encoding fails.
|
||||
public static func encode(state: ConsentState, config: ConsentConfig) -> String? {
|
||||
guard state.hasInteracted, let consentedAt = state.consentedAt else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Derive the set of consented TCF purpose IDs from accepted categories.
|
||||
let consentedPurposeIds: Set<Int> = state.accepted.reduce(into: []) { result, category in
|
||||
category.tcfPurposeIds.forEach { result.insert($0) }
|
||||
}
|
||||
|
||||
return buildCoreString(
|
||||
consentedAt: consentedAt,
|
||||
consentedPurposeIds: consentedPurposeIds
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Core String Construction
|
||||
|
||||
private static func buildCoreString(
|
||||
consentedAt: Date,
|
||||
consentedPurposeIds: Set<Int>
|
||||
) -> String? {
|
||||
var bits = BitWriter()
|
||||
|
||||
// --- Core segment fields (IAB TCF v2.2 spec, Table 1) ---
|
||||
|
||||
// Version (6 bits)
|
||||
bits.write(specVersion, bitCount: 6)
|
||||
|
||||
// Created — deciseconds since epoch (36 bits)
|
||||
let deciseconds = Int(consentedAt.timeIntervalSince1970 * 10)
|
||||
bits.write(deciseconds, bitCount: 36)
|
||||
|
||||
// LastUpdated — deciseconds since epoch (36 bits)
|
||||
bits.write(deciseconds, bitCount: 36)
|
||||
|
||||
// CmpId (12 bits)
|
||||
bits.write(cmpId, bitCount: 12)
|
||||
|
||||
// CmpVersion (12 bits)
|
||||
bits.write(cmpVersion, bitCount: 12)
|
||||
|
||||
// ConsentScreen (6 bits) — screen number within the CMP UI
|
||||
bits.write(1, bitCount: 6)
|
||||
|
||||
// ConsentLanguage (12 bits) — two 6-bit characters, A=0 … Z=25
|
||||
let langBits = encodeTwoLetterLanguage(consentLanguage)
|
||||
bits.write(langBits.0, bitCount: 6)
|
||||
bits.write(langBits.1, bitCount: 6)
|
||||
|
||||
// VendorListVersion (12 bits)
|
||||
bits.write(vendorListVersion, bitCount: 12)
|
||||
|
||||
// TcfPolicyVersion (6 bits) — must be 4 for TCF v2.2
|
||||
bits.write(4, bitCount: 6)
|
||||
|
||||
// IsServiceSpecific (1 bit)
|
||||
bits.write(0, bitCount: 1)
|
||||
|
||||
// UseNonStandardTexts (1 bit)
|
||||
bits.write(0, bitCount: 1)
|
||||
|
||||
// SpecialFeatureOptIns (12 bits) — none opted in
|
||||
bits.write(0, bitCount: 12)
|
||||
|
||||
// PurposesConsent (24 bits) — one bit per purpose, LSB = purpose 1
|
||||
for purposeId in 1 ... tcfPurposeCount {
|
||||
bits.write(consentedPurposeIds.contains(purposeId) ? 1 : 0, bitCount: 1)
|
||||
}
|
||||
|
||||
// PurposesLITransparency (24 bits) — legitimate interest; none asserted
|
||||
bits.write(0, bitCount: 24)
|
||||
|
||||
// PurposeOneTreatment (1 bit)
|
||||
bits.write(0, bitCount: 1)
|
||||
|
||||
// PublisherCC (12 bits) — "GB"
|
||||
let ccBits = encodeTwoLetterLanguage("GB")
|
||||
bits.write(ccBits.0, bitCount: 6)
|
||||
bits.write(ccBits.1, bitCount: 6)
|
||||
|
||||
// Vendor Consents — using BitRange encoding with MaxVendorId = 0 (no vendors)
|
||||
bits.write(0, bitCount: 16) // MaxVendorId
|
||||
bits.write(0, bitCount: 1) // IsRangeEncoding = false
|
||||
// (no bits to write for an empty vendor list)
|
||||
|
||||
// Vendor Legitimate Interests — MaxVendorId = 0
|
||||
bits.write(0, bitCount: 16)
|
||||
bits.write(0, bitCount: 1)
|
||||
|
||||
// Publisher Restrictions count = 0
|
||||
bits.write(0, bitCount: 12)
|
||||
|
||||
// Serialise and Base64url-encode
|
||||
let data = bits.toData()
|
||||
return base64UrlEncode(data)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Encodes a two-letter language/country code into two 6-bit integers (A=0, Z=25).
|
||||
private static func encodeTwoLetterLanguage(_ code: String) -> (Int, Int) {
|
||||
// ASCII value of 'A' is 65. Subtracting this gives 0-based index (A=0 … Z=25).
|
||||
let asciiA: Int = 65
|
||||
let upper = code.uppercased()
|
||||
let chars = Array(upper)
|
||||
guard chars.count == 2,
|
||||
let first = chars[0].asciiValue,
|
||||
let second = chars[1].asciiValue else {
|
||||
return (4, 13) // "EN" fallback (E=4, N=13)
|
||||
}
|
||||
return (Int(first) - asciiA, Int(second) - asciiA)
|
||||
}
|
||||
|
||||
/// Converts a `Data` value to a Base64url string (RFC 4648, no padding).
|
||||
private static func base64UrlEncode(_ data: Data) -> String {
|
||||
data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "="))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bit Writer
|
||||
|
||||
/// A utility type for packing integers into a bit-level byte buffer.
|
||||
private struct BitWriter {
|
||||
|
||||
private var bytes: [UInt8] = []
|
||||
private var currentByte: UInt8 = 0
|
||||
private var bitPosition: Int = 0 // 0 = MSB of current byte
|
||||
|
||||
/// Writes `bitCount` bits from the MSB of `value`.
|
||||
mutating func write(_ value: Int, bitCount: Int) {
|
||||
for i in stride(from: bitCount - 1, through: 0, by: -1) {
|
||||
let bit: UInt8 = (value >> i) & 1 == 1 ? 1 : 0
|
||||
currentByte |= bit << (7 - bitPosition)
|
||||
bitPosition += 1
|
||||
if bitPosition == 8 {
|
||||
bytes.append(currentByte)
|
||||
currentByte = 0
|
||||
bitPosition = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flushes any remaining partial byte and returns the accumulated data.
|
||||
func toData() -> Data {
|
||||
var result = bytes
|
||||
if bitPosition > 0 {
|
||||
result.append(currentByte)
|
||||
}
|
||||
return Data(result)
|
||||
}
|
||||
}
|
||||
111
sdks/ios/ConsentOS/Sources/ConsentOSUI/BannerTheme.swift
Normal file
111
sdks/ios/ConsentOS/Sources/ConsentOSUI/BannerTheme.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
#if canImport(SwiftUI)
|
||||
import SwiftUI
|
||||
import ConsentOSCore
|
||||
|
||||
// MARK: - Banner Theme
|
||||
|
||||
/// Resolved colour and typography theme for the consent banner.
|
||||
///
|
||||
/// Derived from ``ConsentConfig/BannerConfig`` values, falling back to sensible defaults.
|
||||
public struct BannerTheme: Sendable {
|
||||
|
||||
// MARK: - Colours
|
||||
|
||||
public let backgroundColor: Color
|
||||
public let textColor: Color
|
||||
public let accentColor: Color
|
||||
public let secondaryTextColor: Color
|
||||
public let dividerColor: Color
|
||||
public let acceptButtonBackground: Color
|
||||
public let acceptButtonTextColor: Color
|
||||
public let rejectButtonBackground: Color
|
||||
public let rejectButtonTextColor: Color
|
||||
|
||||
// MARK: - Typography
|
||||
|
||||
public let titleFont: Font
|
||||
public let bodyFont: Font
|
||||
public let buttonFont: Font
|
||||
public let captionFont: Font
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
public let cornerRadius: CGFloat
|
||||
public let horizontalPadding: CGFloat
|
||||
public let verticalPadding: CGFloat
|
||||
public let buttonHeight: CGFloat
|
||||
|
||||
// MARK: - Button Labels
|
||||
|
||||
public let acceptButtonText: String
|
||||
public let rejectButtonText: String
|
||||
public let manageButtonText: String
|
||||
public let title: String
|
||||
public let description: String
|
||||
|
||||
// MARK: - Defaults
|
||||
|
||||
static let defaultAccentHex = "#1A73E8" // Google-blue — overridden by brand config
|
||||
static let defaultBackgroundHex = "#FFFFFF"
|
||||
static let defaultTextHex = "#1A1A1A"
|
||||
|
||||
// MARK: - Factory
|
||||
|
||||
/// Creates a ``BannerTheme`` from the banner configuration returned by the API.
|
||||
///
|
||||
/// Hex values not present in the config fall back to neutral defaults.
|
||||
public static func from(config: ConsentConfig.BannerConfig) -> BannerTheme {
|
||||
let background = Color(hex: config.backgroundColor) ?? Color(.systemBackground)
|
||||
let text = Color(hex: config.textColor) ?? Color(.label)
|
||||
let accent = Color(hex: config.accentColor) ?? Color(hex: defaultAccentHex)!
|
||||
|
||||
return BannerTheme(
|
||||
backgroundColor: background,
|
||||
textColor: text,
|
||||
accentColor: accent,
|
||||
secondaryTextColor: text.opacity(0.6),
|
||||
dividerColor: text.opacity(0.12),
|
||||
acceptButtonBackground: accent,
|
||||
acceptButtonTextColor: .white,
|
||||
rejectButtonBackground: Color(.secondarySystemBackground),
|
||||
rejectButtonTextColor: text,
|
||||
titleFont: .headline,
|
||||
bodyFont: .subheadline,
|
||||
buttonFont: .subheadline.weight(.semibold),
|
||||
captionFont: .caption,
|
||||
cornerRadius: 12,
|
||||
horizontalPadding: 16,
|
||||
verticalPadding: 16,
|
||||
buttonHeight: 44,
|
||||
acceptButtonText: config.acceptButtonText ?? "Accept All",
|
||||
rejectButtonText: config.rejectButtonText ?? "Reject All",
|
||||
manageButtonText: config.manageButtonText ?? "Manage Preferences",
|
||||
title: config.title ?? "We value your privacy",
|
||||
description: config.description ?? "We use cookies to improve your experience and for analytics."
|
||||
)
|
||||
}
|
||||
|
||||
/// Default theme used when no configuration has been loaded yet.
|
||||
public static var defaultTheme: BannerTheme {
|
||||
from(config: ConsentConfig.BannerConfig())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Colour Hex Extension
|
||||
|
||||
extension Color {
|
||||
/// Initialises a `Color` from a hex string such as `"#1A73E8"` or `"1A73E8"`.
|
||||
init?(hex: String?) {
|
||||
guard let hex else { return nil }
|
||||
let sanitised = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
||||
guard sanitised.count == 6,
|
||||
let value = UInt64(sanitised, radix: 16) else {
|
||||
return nil
|
||||
}
|
||||
let r = Double((value >> 16) & 0xFF) / 255
|
||||
let g = Double((value >> 8) & 0xFF) / 255
|
||||
let b = Double((value ) & 0xFF) / 255
|
||||
self.init(red: r, green: g, blue: b)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
275
sdks/ios/ConsentOS/Sources/ConsentOSUI/ConsentBannerView.swift
Normal file
275
sdks/ios/ConsentOS/Sources/ConsentOSUI/ConsentBannerView.swift
Normal file
@@ -0,0 +1,275 @@
|
||||
#if canImport(SwiftUI)
|
||||
import SwiftUI
|
||||
import ConsentOSCore
|
||||
|
||||
// MARK: - Consent Banner View
|
||||
|
||||
/// A SwiftUI consent banner that respects the site's ``BannerTheme``.
|
||||
///
|
||||
/// The banner displays in a bottom-sheet style by default. It presents three
|
||||
/// actions: accept all, reject all, and manage preferences (category-level toggles).
|
||||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// ConsentBannerView(theme: theme) {
|
||||
/// await ConsentOS.shared.acceptAll()
|
||||
/// } onRejectAll: {
|
||||
/// await ConsentOS.shared.rejectAll()
|
||||
/// } onSave: { categories in
|
||||
/// await ConsentOS.shared.acceptCategories(categories)
|
||||
/// }
|
||||
/// ```
|
||||
public struct ConsentBannerView: View {
|
||||
|
||||
// MARK: - State
|
||||
|
||||
@State private var showingManage: Bool = false
|
||||
@State private var categoryToggles: [ConsentCategory: Bool] = [:]
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private let theme: BannerTheme
|
||||
private let onAcceptAll: () async -> Void
|
||||
private let onRejectAll: () async -> Void
|
||||
private let onSave: (Set<ConsentCategory>) async -> Void
|
||||
|
||||
/// The categories available for granular control (excludes `necessary`).
|
||||
private let manageableCategories: [ConsentCategory] = ConsentCategory.allCases
|
||||
.filter { $0.requiresConsent }
|
||||
|
||||
// MARK: - Initialisers
|
||||
|
||||
public init(
|
||||
theme: BannerTheme = .defaultTheme,
|
||||
onAcceptAll: @escaping () async -> Void,
|
||||
onRejectAll: @escaping () async -> Void,
|
||||
onSave: @escaping (Set<ConsentCategory>) async -> Void
|
||||
) {
|
||||
self.theme = theme
|
||||
self.onAcceptAll = onAcceptAll
|
||||
self.onRejectAll = onRejectAll
|
||||
self.onSave = onSave
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if showingManage {
|
||||
manageView
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
} else {
|
||||
mainBannerView
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.background(theme.backgroundColor)
|
||||
.clipShape(RoundedRectangle(cornerRadius: theme.cornerRadius, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.1), radius: 12, x: 0, y: -4)
|
||||
.padding(.horizontal, theme.horizontalPadding)
|
||||
.onAppear {
|
||||
// Default all optional categories to off
|
||||
for category in manageableCategories {
|
||||
categoryToggles[category] = false
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: showingManage)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Cookie consent banner")
|
||||
}
|
||||
|
||||
// MARK: - Main Banner
|
||||
|
||||
private var mainBannerView: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(theme.title)
|
||||
.font(theme.titleFont)
|
||||
.foregroundColor(theme.textColor)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
|
||||
Text(theme.description)
|
||||
.font(theme.bodyFont)
|
||||
.foregroundColor(theme.secondaryTextColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Divider()
|
||||
.background(theme.dividerColor)
|
||||
|
||||
// Action buttons
|
||||
VStack(spacing: 8) {
|
||||
acceptButton
|
||||
HStack(spacing: 8) {
|
||||
rejectButton
|
||||
manageButton
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(theme.verticalPadding)
|
||||
}
|
||||
|
||||
// MARK: - Manage View
|
||||
|
||||
private var manageView: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Button(action: { showingManage = false }) {
|
||||
Image(systemName: "chevron.left")
|
||||
.foregroundColor(theme.accentColor)
|
||||
}
|
||||
.accessibilityLabel("Back")
|
||||
|
||||
Text(theme.manageButtonText)
|
||||
.font(theme.titleFont)
|
||||
.foregroundColor(theme.textColor)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(theme.verticalPadding)
|
||||
|
||||
Divider().background(theme.dividerColor)
|
||||
|
||||
// Necessary category — always on
|
||||
categoryRow(
|
||||
name: ConsentCategory.necessary.displayName,
|
||||
description: ConsentCategory.necessary.displayDescription,
|
||||
isOn: .constant(true),
|
||||
isToggleable: false
|
||||
)
|
||||
|
||||
Divider().background(theme.dividerColor)
|
||||
|
||||
// Optional categories
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(manageableCategories, id: \.rawValue) { category in
|
||||
categoryRow(
|
||||
name: category.displayName,
|
||||
description: category.displayDescription,
|
||||
isOn: Binding(
|
||||
get: { categoryToggles[category] ?? false },
|
||||
set: { categoryToggles[category] = $0 }
|
||||
),
|
||||
isToggleable: true
|
||||
)
|
||||
Divider().background(theme.dividerColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save button
|
||||
Button(action: {
|
||||
Task {
|
||||
let selected = Set(
|
||||
manageableCategories.filter { categoryToggles[$0] == true }
|
||||
)
|
||||
await onSave(selected)
|
||||
}
|
||||
}) {
|
||||
Text("Save Preferences")
|
||||
.font(theme.buttonFont)
|
||||
.foregroundColor(theme.acceptButtonTextColor)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: theme.buttonHeight)
|
||||
.background(theme.accentColor)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
.padding(theme.verticalPadding)
|
||||
.accessibilityLabel("Save your cookie preferences")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Row
|
||||
|
||||
private func categoryRow(
|
||||
name: String,
|
||||
description: String,
|
||||
isOn: Binding<Bool>,
|
||||
isToggleable: Bool
|
||||
) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(name)
|
||||
.font(theme.bodyFont.weight(.semibold))
|
||||
.foregroundColor(theme.textColor)
|
||||
Text(description)
|
||||
.font(theme.captionFont)
|
||||
.foregroundColor(theme.secondaryTextColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: isOn)
|
||||
.labelsHidden()
|
||||
.toggleStyle(SwitchToggleStyle(tint: theme.accentColor))
|
||||
.disabled(!isToggleable)
|
||||
.accessibilityLabel(isToggleable ? "Toggle \(name)" : "\(name) always active")
|
||||
}
|
||||
.padding(.horizontal, theme.horizontalPadding)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
// MARK: - Buttons
|
||||
|
||||
private var acceptButton: some View {
|
||||
Button(action: {
|
||||
Task { await onAcceptAll() }
|
||||
}) {
|
||||
Text(theme.acceptButtonText)
|
||||
.font(theme.buttonFont)
|
||||
.foregroundColor(theme.acceptButtonTextColor)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: theme.buttonHeight)
|
||||
.background(theme.acceptButtonBackground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
.accessibilityLabel("Accept all cookies")
|
||||
}
|
||||
|
||||
private var rejectButton: some View {
|
||||
Button(action: {
|
||||
Task { await onRejectAll() }
|
||||
}) {
|
||||
Text(theme.rejectButtonText)
|
||||
.font(theme.buttonFont)
|
||||
.foregroundColor(theme.rejectButtonTextColor)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: theme.buttonHeight)
|
||||
.background(theme.rejectButtonBackground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
.accessibilityLabel("Reject all non-essential cookies")
|
||||
}
|
||||
|
||||
private var manageButton: some View {
|
||||
Button(action: { showingManage = true }) {
|
||||
Text(theme.manageButtonText)
|
||||
.font(theme.buttonFont)
|
||||
.foregroundColor(theme.accentColor)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: theme.buttonHeight)
|
||||
.background(theme.accentColor.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
.accessibilityLabel("Manage your cookie preferences")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Main Banner") {
|
||||
VStack {
|
||||
Spacer()
|
||||
ConsentBannerView(
|
||||
theme: .defaultTheme,
|
||||
onAcceptAll: {},
|
||||
onRejectAll: {},
|
||||
onSave: { _ in }
|
||||
)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,106 @@
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import ConsentOSCore
|
||||
|
||||
// MARK: - Consent Modal Controller
|
||||
|
||||
/// A UIKit view controller that presents the SwiftUI ``ConsentBannerView`` as a
|
||||
/// bottom-sheet modal overlay.
|
||||
///
|
||||
/// Use this when your app is primarily UIKit-based.
|
||||
///
|
||||
/// ```swift
|
||||
/// let modal = ConsentModalController()
|
||||
/// modal.onDismiss = { [weak self] in
|
||||
/// // Banner dismissed
|
||||
/// }
|
||||
/// present(modal, animated: true)
|
||||
/// ```
|
||||
public final class ConsentModalController: UIViewController {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// Called when the banner is dismissed (after any consent action).
|
||||
public var onDismiss: (() -> Void)?
|
||||
|
||||
/// The theme to apply to the banner. Defaults to ``BannerTheme/defaultTheme``.
|
||||
public var theme: BannerTheme = .defaultTheme
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var hostingController: UIHostingController<AnyView>?
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor.black.withAlphaComponent(0.4)
|
||||
|
||||
setupBannerView()
|
||||
setupBackgroundDismiss()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupBannerView() {
|
||||
let bannerView = ConsentBannerView(
|
||||
theme: theme,
|
||||
onAcceptAll: { [weak self] in
|
||||
await ConsentOS.shared.acceptAll()
|
||||
await MainActor.run { self?.dismiss(animated: true) }
|
||||
},
|
||||
onRejectAll: { [weak self] in
|
||||
await ConsentOS.shared.rejectAll()
|
||||
await MainActor.run { self?.dismiss(animated: true) }
|
||||
},
|
||||
onSave: { [weak self] categories in
|
||||
await ConsentOS.shared.acceptCategories(categories)
|
||||
await MainActor.run { self?.dismiss(animated: true) }
|
||||
}
|
||||
)
|
||||
|
||||
let hosting = UIHostingController(rootView: AnyView(
|
||||
VStack {
|
||||
Spacer()
|
||||
bannerView
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
))
|
||||
hosting.view.backgroundColor = .clear
|
||||
|
||||
addChild(hosting)
|
||||
view.addSubview(hosting.view)
|
||||
hosting.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
hosting.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
])
|
||||
hosting.didMove(toParent: self)
|
||||
self.hostingController = hosting
|
||||
}
|
||||
|
||||
private func setupBackgroundDismiss() {
|
||||
// Tapping the dim overlay does not dismiss — users must interact with the banner.
|
||||
// Remove this behaviour if your design requires tap-to-dismiss.
|
||||
}
|
||||
|
||||
// MARK: - Dismiss Override
|
||||
|
||||
public override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
|
||||
super.dismiss(animated: flag) { [weak self] in
|
||||
self?.onDismiss?()
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Accessibility
|
||||
|
||||
public override func accessibilityPerformEscape() -> Bool {
|
||||
// Do not allow VoiceOver escape gesture to dismiss the banner without a consent choice.
|
||||
return false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
22
sdks/ios/ConsentOS/Sources/ConsentOSUI/ConsentOS+UIKit.swift
Normal file
22
sdks/ios/ConsentOS/Sources/ConsentOSUI/ConsentOS+UIKit.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
import ConsentOSCore
|
||||
|
||||
// MARK: - UIKit Banner Presentation (ConsentOSUI extension)
|
||||
|
||||
public extension ConsentOS {
|
||||
/// Presents the consent banner modally on the given view controller.
|
||||
///
|
||||
/// This extension is provided by the ConsentOSUI module.
|
||||
/// Ensure you import ConsentOSUI alongside ConsentOSCore to use this method.
|
||||
///
|
||||
/// - Parameter viewController: The presenting view controller.
|
||||
@MainActor
|
||||
func showBanner(on viewController: UIViewController) {
|
||||
let modal = ConsentModalController()
|
||||
modal.modalPresentationStyle = .overFullScreen
|
||||
modal.modalTransitionStyle = .crossDissolve
|
||||
viewController.present(modal, animated: true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,200 @@
|
||||
import XCTest
|
||||
@testable import ConsentOSCore
|
||||
|
||||
// MARK: - Mock API
|
||||
|
||||
/// In-memory API implementation for testing without network calls.
|
||||
final class MockConsentAPI: ConsentAPIProtocol, @unchecked Sendable {
|
||||
|
||||
// MARK: - Configurable Behaviour
|
||||
|
||||
var configToReturn: ConsentConfig?
|
||||
var errorToThrow: Error?
|
||||
var postConsentError: Error?
|
||||
|
||||
// MARK: - Call Tracking
|
||||
|
||||
private(set) var fetchConfigCallCount = 0
|
||||
private(set) var postConsentCallCount = 0
|
||||
private(set) var lastPostedPayload: ConsentPayload?
|
||||
private(set) var lastFetchedSiteId: String?
|
||||
|
||||
func fetchConfig(siteId: String) async throws -> ConsentConfig {
|
||||
fetchConfigCallCount += 1
|
||||
lastFetchedSiteId = siteId
|
||||
if let error = errorToThrow { throw error }
|
||||
guard let config = configToReturn else {
|
||||
throw ConsentAPIError.unexpectedStatusCode(404)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func postConsent(_ payload: ConsentPayload) async throws {
|
||||
postConsentCallCount += 1
|
||||
lastPostedPayload = payload
|
||||
if let error = postConsentError { throw error }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
final class ConsentAPITests: XCTestCase {
|
||||
|
||||
private var mockAPI: MockConsentAPI!
|
||||
private let siteId = "test-site-001"
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
mockAPI = MockConsentAPI()
|
||||
}
|
||||
|
||||
// MARK: - fetchConfig
|
||||
|
||||
func test_fetchConfig_returnsConfig_whenSuccessful() async throws {
|
||||
mockAPI.configToReturn = makeSampleConfig()
|
||||
|
||||
let result = try await mockAPI.fetchConfig(siteId: siteId)
|
||||
|
||||
XCTAssertEqual(result.siteId, siteId)
|
||||
XCTAssertEqual(mockAPI.fetchConfigCallCount, 1)
|
||||
XCTAssertEqual(mockAPI.lastFetchedSiteId, siteId)
|
||||
}
|
||||
|
||||
func test_fetchConfig_throwsError_onNetworkFailure() async {
|
||||
mockAPI.errorToThrow = ConsentAPIError.networkFailure(
|
||||
NSError(domain: "NSURLErrorDomain", code: -1009)
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try await mockAPI.fetchConfig(siteId: siteId)
|
||||
XCTFail("Expected an error to be thrown")
|
||||
} catch ConsentAPIError.networkFailure {
|
||||
// Expected
|
||||
} catch {
|
||||
XCTFail("Unexpected error type: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func test_fetchConfig_throwsError_on404() async {
|
||||
mockAPI.errorToThrow = ConsentAPIError.unexpectedStatusCode(404)
|
||||
|
||||
do {
|
||||
_ = try await mockAPI.fetchConfig(siteId: siteId)
|
||||
XCTFail("Expected an error to be thrown")
|
||||
} catch ConsentAPIError.unexpectedStatusCode(let code) {
|
||||
XCTAssertEqual(code, 404)
|
||||
} catch {
|
||||
XCTFail("Unexpected error type: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - postConsent
|
||||
|
||||
func test_postConsent_sendsCorrectPayload() async throws {
|
||||
let consentedAt = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let payload = ConsentPayload(
|
||||
siteId: siteId,
|
||||
visitorId: "visitor-xyz",
|
||||
accepted: [.analytics, .functional],
|
||||
rejected: [.marketing],
|
||||
consentedAt: consentedAt,
|
||||
bannerVersion: "v2",
|
||||
tcString: "test-tc-string"
|
||||
)
|
||||
|
||||
try await mockAPI.postConsent(payload)
|
||||
|
||||
XCTAssertEqual(mockAPI.postConsentCallCount, 1)
|
||||
let sent = try XCTUnwrap(mockAPI.lastPostedPayload)
|
||||
XCTAssertEqual(sent.siteId, siteId)
|
||||
XCTAssertEqual(sent.visitorId, "visitor-xyz")
|
||||
XCTAssertEqual(sent.platform, "ios")
|
||||
XCTAssertTrue(sent.accepted.contains("analytics"))
|
||||
XCTAssertTrue(sent.accepted.contains("functional"))
|
||||
XCTAssertTrue(sent.rejected.contains("marketing"))
|
||||
XCTAssertEqual(sent.bannerVersion, "v2")
|
||||
XCTAssertEqual(sent.tcString, "test-tc-string")
|
||||
}
|
||||
|
||||
func test_postConsent_platformAlwaysIOS() async throws {
|
||||
let payload = ConsentPayload(
|
||||
siteId: siteId,
|
||||
visitorId: "v",
|
||||
accepted: [],
|
||||
rejected: [],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: nil
|
||||
)
|
||||
|
||||
try await mockAPI.postConsent(payload)
|
||||
|
||||
XCTAssertEqual(mockAPI.lastPostedPayload?.platform, "ios")
|
||||
}
|
||||
|
||||
func test_postConsent_throwsError_onFailure() async {
|
||||
mockAPI.postConsentError = ConsentAPIError.unexpectedStatusCode(500)
|
||||
|
||||
let payload = ConsentPayload(
|
||||
siteId: siteId,
|
||||
visitorId: "v",
|
||||
accepted: [],
|
||||
rejected: [],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: nil
|
||||
)
|
||||
|
||||
do {
|
||||
try await mockAPI.postConsent(payload)
|
||||
XCTFail("Expected error")
|
||||
} catch ConsentAPIError.unexpectedStatusCode(let code) {
|
||||
XCTAssertEqual(code, 500)
|
||||
} catch {
|
||||
XCTFail("Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ConsentPayload Serialisation
|
||||
|
||||
func test_consentPayload_encodesCategoriesToRawValues() throws {
|
||||
let payload = ConsentPayload(
|
||||
siteId: "s1",
|
||||
visitorId: "v1",
|
||||
accepted: [.analytics, .marketing],
|
||||
rejected: [.functional],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: nil
|
||||
)
|
||||
|
||||
XCTAssertTrue(payload.accepted.contains("analytics"))
|
||||
XCTAssertTrue(payload.accepted.contains("marketing"))
|
||||
XCTAssertTrue(payload.rejected.contains("functional"))
|
||||
XCTAssertFalse(payload.accepted.contains("necessary"))
|
||||
}
|
||||
|
||||
// MARK: - Error Descriptions
|
||||
|
||||
func test_invalidURL_hasDescription() {
|
||||
let error = ConsentAPIError.invalidURL
|
||||
XCTAssertNotNil(error.errorDescription)
|
||||
XCTAssertFalse(error.errorDescription!.isEmpty)
|
||||
}
|
||||
|
||||
func test_unexpectedStatusCode_includesCodeInDescription() {
|
||||
let error = ConsentAPIError.unexpectedStatusCode(503)
|
||||
XCTAssertTrue(error.errorDescription?.contains("503") ?? false)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeSampleConfig() -> ConsentConfig {
|
||||
ConsentConfig(
|
||||
siteId: siteId,
|
||||
siteName: "Test Site",
|
||||
blockingMode: .optIn,
|
||||
consentExpiryDays: 365,
|
||||
bannerVersion: "v1",
|
||||
bannerConfig: ConsentConfig.BannerConfig(),
|
||||
categories: []
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import XCTest
|
||||
@testable import ConsentOSCore
|
||||
|
||||
final class ConsentCategoryTests: XCTestCase {
|
||||
|
||||
// MARK: - requiresConsent
|
||||
|
||||
func test_necessary_doesNotRequireConsent() {
|
||||
XCTAssertFalse(ConsentCategory.necessary.requiresConsent)
|
||||
}
|
||||
|
||||
func test_allOtherCategories_requireConsent() {
|
||||
let optionalCategories = ConsentCategory.allCases.filter { $0 != .necessary }
|
||||
for category in optionalCategories {
|
||||
XCTAssertTrue(category.requiresConsent, "\(category) should require consent")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - GCM Mappings
|
||||
|
||||
func test_necessary_hasNoGCMType() {
|
||||
XCTAssertNil(ConsentCategory.necessary.gcmConsentType)
|
||||
}
|
||||
|
||||
func test_functional_mapsToFunctionalityStorage() {
|
||||
XCTAssertEqual(ConsentCategory.functional.gcmConsentType, "functionality_storage")
|
||||
}
|
||||
|
||||
func test_analytics_mapsToAnalyticsStorage() {
|
||||
XCTAssertEqual(ConsentCategory.analytics.gcmConsentType, "analytics_storage")
|
||||
}
|
||||
|
||||
func test_marketing_mapsToAdStorage() {
|
||||
XCTAssertEqual(ConsentCategory.marketing.gcmConsentType, "ad_storage")
|
||||
}
|
||||
|
||||
func test_personalisation_mapsToPersonalizationStorage() {
|
||||
XCTAssertEqual(ConsentCategory.personalisation.gcmConsentType, "personalization_storage")
|
||||
}
|
||||
|
||||
// MARK: - TCF Purpose IDs
|
||||
|
||||
func test_necessary_hasNoTCFPurposes() {
|
||||
XCTAssertTrue(ConsentCategory.necessary.tcfPurposeIds.isEmpty)
|
||||
}
|
||||
|
||||
func test_functional_hasExpectedTCFPurposes() {
|
||||
XCTAssertEqual(ConsentCategory.functional.tcfPurposeIds, [1])
|
||||
}
|
||||
|
||||
func test_analytics_hasExpectedTCFPurposes() {
|
||||
XCTAssertEqual(ConsentCategory.analytics.tcfPurposeIds, [7, 8, 9, 10])
|
||||
}
|
||||
|
||||
func test_marketing_hasExpectedTCFPurposes() {
|
||||
XCTAssertEqual(ConsentCategory.marketing.tcfPurposeIds, [2, 3, 4])
|
||||
}
|
||||
|
||||
func test_personalisation_hasExpectedTCFPurposes() {
|
||||
XCTAssertEqual(ConsentCategory.personalisation.tcfPurposeIds, [5, 6])
|
||||
}
|
||||
|
||||
// MARK: - Display Names
|
||||
|
||||
func test_allCategories_haveNonEmptyDisplayNames() {
|
||||
for category in ConsentCategory.allCases {
|
||||
XCTAssertFalse(category.displayName.isEmpty, "\(category) missing display name")
|
||||
XCTAssertFalse(category.displayDescription.isEmpty, "\(category) missing description")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codable
|
||||
|
||||
func test_category_roundTripsViaJSON() throws {
|
||||
for category in ConsentCategory.allCases {
|
||||
let encoded = try JSONEncoder().encode(category)
|
||||
let decoded = try JSONDecoder().decode(ConsentCategory.self, from: encoded)
|
||||
XCTAssertEqual(decoded, category)
|
||||
}
|
||||
}
|
||||
|
||||
func test_category_decodesFromRawValue() throws {
|
||||
let json = #""analytics""#.data(using: .utf8)!
|
||||
let decoded = try JSONDecoder().decode(ConsentCategory.self, from: json)
|
||||
XCTAssertEqual(decoded, .analytics)
|
||||
}
|
||||
|
||||
// MARK: - CaseIterable
|
||||
|
||||
func test_allCases_containsFiveCategories() {
|
||||
XCTAssertEqual(ConsentCategory.allCases.count, 5)
|
||||
}
|
||||
|
||||
func test_allCases_containsExpectedMembers() {
|
||||
let expected: Set<ConsentCategory> = [
|
||||
.necessary, .functional, .analytics, .marketing, .personalisation
|
||||
]
|
||||
XCTAssertEqual(Set(ConsentCategory.allCases), expected)
|
||||
}
|
||||
}
|
||||
292
sdks/ios/ConsentOS/Tests/ConsentOSCoreTests/ConsentOSTests.swift
Normal file
292
sdks/ios/ConsentOS/Tests/ConsentOSCoreTests/ConsentOSTests.swift
Normal file
@@ -0,0 +1,292 @@
|
||||
import XCTest
|
||||
@testable import ConsentOSCore
|
||||
|
||||
// MARK: - Mock Storage
|
||||
|
||||
final class MockConsentStorage: ConsentStorageProtocol, @unchecked Sendable {
|
||||
|
||||
var storedState: ConsentState?
|
||||
var storedCachedConfig: CachedConfig?
|
||||
var storedVisitorId: String = UUID().uuidString
|
||||
|
||||
private(set) var saveStateCallCount = 0
|
||||
private(set) var clearStateCallCount = 0
|
||||
|
||||
func loadState() -> ConsentState? { storedState }
|
||||
|
||||
func saveState(_ state: ConsentState) {
|
||||
saveStateCallCount += 1
|
||||
storedState = state
|
||||
}
|
||||
|
||||
func clearState() {
|
||||
clearStateCallCount += 1
|
||||
storedState = nil
|
||||
}
|
||||
|
||||
func loadCachedConfig() -> CachedConfig? { storedCachedConfig }
|
||||
|
||||
func saveCachedConfig(_ cached: CachedConfig) {
|
||||
storedCachedConfig = cached
|
||||
}
|
||||
|
||||
func clearCachedConfig() {
|
||||
storedCachedConfig = nil
|
||||
}
|
||||
|
||||
func visitorId() -> String { storedVisitorId }
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
final class ConsentOSTests: XCTestCase {
|
||||
|
||||
private var sdk: ConsentOS!
|
||||
private var mockStorage: MockConsentStorage!
|
||||
private var mockAPI: MockConsentAPI!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
mockStorage = MockConsentStorage()
|
||||
mockAPI = MockConsentAPI()
|
||||
sdk = ConsentOS(storage: mockStorage, api: mockAPI)
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
func test_configure_setsIsConfigured() {
|
||||
XCTAssertFalse(sdk.isConfigured)
|
||||
sdk.configure(
|
||||
siteId: "site-001",
|
||||
apiBase: URL(string: "https://api.example.com")!
|
||||
)
|
||||
XCTAssertTrue(sdk.isConfigured)
|
||||
}
|
||||
|
||||
func test_configure_setsSiteId() {
|
||||
sdk.configure(siteId: "my-site", apiBase: URL(string: "https://api.example.com")!)
|
||||
XCTAssertEqual(sdk.siteId, "my-site")
|
||||
}
|
||||
|
||||
func test_configure_restoresPersistedState() {
|
||||
let existing = ConsentState(
|
||||
visitorId: mockStorage.storedVisitorId,
|
||||
accepted: [.analytics],
|
||||
rejected: [],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: "v1"
|
||||
)
|
||||
mockStorage.storedState = existing
|
||||
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
|
||||
XCTAssertEqual(sdk.consentState?.accepted, [.analytics])
|
||||
}
|
||||
|
||||
func test_configure_createsNewState_whenNoPersistedState() {
|
||||
mockStorage.storedState = nil
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
|
||||
XCTAssertNotNil(sdk.consentState)
|
||||
XCTAssertFalse(sdk.consentState!.hasInteracted)
|
||||
}
|
||||
|
||||
// MARK: - shouldShowBanner
|
||||
|
||||
func test_shouldShowBanner_returnsTrue_whenNoInteraction() async {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
// State has no interaction (consentedAt is nil)
|
||||
let shouldShow = await sdk.shouldShowBanner()
|
||||
XCTAssertTrue(shouldShow)
|
||||
}
|
||||
|
||||
func test_shouldShowBanner_returnsFalse_whenRecentConsentExists() async {
|
||||
mockAPI.configToReturn = makeSampleConfig(expiryDays: 365, bannerVersion: "v1")
|
||||
|
||||
let recent = ConsentState(
|
||||
visitorId: "v1",
|
||||
accepted: [.analytics],
|
||||
rejected: [.marketing, .functional, .personalisation],
|
||||
consentedAt: Date(), // just now
|
||||
bannerVersion: "v1"
|
||||
)
|
||||
mockStorage.storedState = recent
|
||||
// Pre-load cached config so shouldShowBanner sees it
|
||||
mockStorage.storedCachedConfig = CachedConfig(
|
||||
config: makeSampleConfig(expiryDays: 365, bannerVersion: "v1"),
|
||||
fetchedAt: Date()
|
||||
)
|
||||
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
|
||||
let shouldShow = await sdk.shouldShowBanner()
|
||||
XCTAssertFalse(shouldShow)
|
||||
}
|
||||
|
||||
func test_shouldShowBanner_returnsTrue_whenBannerVersionChanged() async {
|
||||
mockAPI.configToReturn = makeSampleConfig(expiryDays: 365, bannerVersion: "v2")
|
||||
|
||||
let state = ConsentState(
|
||||
visitorId: "v1",
|
||||
accepted: [.analytics],
|
||||
rejected: [],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: "v1" // old version
|
||||
)
|
||||
mockStorage.storedState = state
|
||||
mockStorage.storedCachedConfig = CachedConfig(
|
||||
config: makeSampleConfig(expiryDays: 365, bannerVersion: "v2"),
|
||||
fetchedAt: Date()
|
||||
)
|
||||
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
|
||||
let shouldShow = await sdk.shouldShowBanner()
|
||||
XCTAssertTrue(shouldShow)
|
||||
}
|
||||
|
||||
func test_shouldShowBanner_returnsTrue_whenConsentExpired() async {
|
||||
let expiryDays = 30
|
||||
let consentedAt = Date(timeIntervalSinceNow: -Double(expiryDays * 86_400 + 1))
|
||||
|
||||
let state = ConsentState(
|
||||
visitorId: "v1",
|
||||
accepted: [.analytics],
|
||||
rejected: [],
|
||||
consentedAt: consentedAt,
|
||||
bannerVersion: "v1"
|
||||
)
|
||||
mockStorage.storedState = state
|
||||
mockStorage.storedCachedConfig = CachedConfig(
|
||||
config: makeSampleConfig(expiryDays: expiryDays, bannerVersion: "v1"),
|
||||
fetchedAt: Date()
|
||||
)
|
||||
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
|
||||
let shouldShow = await sdk.shouldShowBanner()
|
||||
XCTAssertTrue(shouldShow)
|
||||
}
|
||||
|
||||
// MARK: - acceptAll
|
||||
|
||||
func test_acceptAll_updatesConsentState() async {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
await sdk.acceptAll()
|
||||
|
||||
let state = sdk.consentState
|
||||
XCTAssertNotNil(state)
|
||||
XCTAssertTrue(state!.hasInteracted)
|
||||
XCTAssertFalse(state!.accepted.isEmpty)
|
||||
}
|
||||
|
||||
func test_acceptAll_grantsAllOptionalCategories() async {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
await sdk.acceptAll()
|
||||
|
||||
for category in ConsentCategory.allCases where category.requiresConsent {
|
||||
XCTAssertTrue(sdk.getConsentStatus(for: category), "\(category) should be granted")
|
||||
}
|
||||
}
|
||||
|
||||
func test_acceptAll_persistsToStorage() async {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
await sdk.acceptAll()
|
||||
|
||||
XCTAssertGreaterThan(mockStorage.saveStateCallCount, 0)
|
||||
XCTAssertNotNil(mockStorage.storedState)
|
||||
}
|
||||
|
||||
func test_acceptAll_postsConsentToAPI() async {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
await sdk.acceptAll()
|
||||
|
||||
XCTAssertEqual(mockAPI.postConsentCallCount, 1)
|
||||
XCTAssertEqual(mockAPI.lastPostedPayload?.platform, "ios")
|
||||
}
|
||||
|
||||
// MARK: - rejectAll
|
||||
|
||||
func test_rejectAll_updatesConsentState() async {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
await sdk.rejectAll()
|
||||
|
||||
let state = sdk.consentState
|
||||
XCTAssertNotNil(state)
|
||||
XCTAssertTrue(state!.hasInteracted)
|
||||
XCTAssertTrue(state!.accepted.isEmpty)
|
||||
}
|
||||
|
||||
func test_rejectAll_deniesAllOptionalCategories() async {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
await sdk.rejectAll()
|
||||
|
||||
for category in ConsentCategory.allCases where category.requiresConsent {
|
||||
XCTAssertFalse(sdk.getConsentStatus(for: category), "\(category) should be denied")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - acceptCategories
|
||||
|
||||
func test_acceptCategories_onlyGrantsSpecifiedCategories() async {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
await sdk.acceptCategories([.analytics, .functional])
|
||||
|
||||
XCTAssertTrue(sdk.getConsentStatus(for: .analytics))
|
||||
XCTAssertTrue(sdk.getConsentStatus(for: .functional))
|
||||
XCTAssertFalse(sdk.getConsentStatus(for: .marketing))
|
||||
XCTAssertFalse(sdk.getConsentStatus(for: .personalisation))
|
||||
}
|
||||
|
||||
// MARK: - getConsentStatus
|
||||
|
||||
func test_getConsentStatus_returnsTrue_forNecessary_always() {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
XCTAssertTrue(sdk.getConsentStatus(for: .necessary))
|
||||
}
|
||||
|
||||
func test_getConsentStatus_returnsFalse_forOptional_beforeInteraction() {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
XCTAssertFalse(sdk.getConsentStatus(for: .analytics))
|
||||
}
|
||||
|
||||
// MARK: - Delegate
|
||||
|
||||
func test_delegate_isNotifiedAfterAcceptAll() async {
|
||||
class MockDelegate: ConsentOSDelegate {
|
||||
var didChangeCalled = false
|
||||
var receivedState: ConsentState?
|
||||
|
||||
func consentDidChange(_ state: ConsentState) {
|
||||
didChangeCalled = true
|
||||
receivedState = state
|
||||
}
|
||||
}
|
||||
|
||||
let delegate = MockDelegate()
|
||||
sdk.delegate = delegate
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
await sdk.acceptAll()
|
||||
|
||||
// Allow the MainActor dispatch to complete
|
||||
await Task.yield()
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertTrue(delegate.didChangeCalled)
|
||||
XCTAssertNotNil(delegate.receivedState)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeSampleConfig(expiryDays: Int = 365, bannerVersion: String = "v1") -> ConsentConfig {
|
||||
ConsentConfig(
|
||||
siteId: "site-001",
|
||||
siteName: "Test",
|
||||
blockingMode: .optIn,
|
||||
consentExpiryDays: expiryDays,
|
||||
bannerVersion: bannerVersion,
|
||||
bannerConfig: ConsentConfig.BannerConfig(),
|
||||
categories: []
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import XCTest
|
||||
@testable import ConsentOSCore
|
||||
|
||||
final class ConsentStateTests: XCTestCase {
|
||||
|
||||
private let visitorId = "test-visitor-123"
|
||||
|
||||
// MARK: - Initial State
|
||||
|
||||
func test_newState_hasNoInteraction() {
|
||||
let state = ConsentState(visitorId: visitorId)
|
||||
XCTAssertFalse(state.hasInteracted)
|
||||
XCTAssertNil(state.consentedAt)
|
||||
XCTAssertTrue(state.accepted.isEmpty)
|
||||
XCTAssertTrue(state.rejected.isEmpty)
|
||||
}
|
||||
|
||||
func test_newState_preservesVisitorId() {
|
||||
let state = ConsentState(visitorId: visitorId)
|
||||
XCTAssertEqual(state.visitorId, visitorId)
|
||||
}
|
||||
|
||||
// MARK: - isGranted
|
||||
|
||||
func test_necessary_isAlwaysGranted() {
|
||||
let state = ConsentState(visitorId: visitorId) // no interaction
|
||||
XCTAssertTrue(state.isGranted(.necessary))
|
||||
}
|
||||
|
||||
func test_optional_isNotGranted_whenNoInteraction() {
|
||||
let state = ConsentState(visitorId: visitorId)
|
||||
for category in ConsentCategory.allCases where category != .necessary {
|
||||
XCTAssertFalse(state.isGranted(category), "\(category) should not be granted by default")
|
||||
}
|
||||
}
|
||||
|
||||
func test_accepted_category_isGranted() {
|
||||
let state = ConsentState(
|
||||
visitorId: visitorId,
|
||||
accepted: [.analytics],
|
||||
rejected: [],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: nil
|
||||
)
|
||||
XCTAssertTrue(state.isGranted(.analytics))
|
||||
}
|
||||
|
||||
func test_rejected_category_isNotGranted() {
|
||||
let state = ConsentState(
|
||||
visitorId: visitorId,
|
||||
accepted: [],
|
||||
rejected: [.analytics],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: nil
|
||||
)
|
||||
XCTAssertFalse(state.isGranted(.analytics))
|
||||
}
|
||||
|
||||
// MARK: - isDenied
|
||||
|
||||
func test_necessary_isNeverDenied() {
|
||||
let state = ConsentState(
|
||||
visitorId: visitorId,
|
||||
accepted: [],
|
||||
rejected: [.analytics],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: nil
|
||||
)
|
||||
XCTAssertFalse(state.isDenied(.necessary))
|
||||
}
|
||||
|
||||
func test_rejected_category_isDenied() {
|
||||
let state = ConsentState(
|
||||
visitorId: visitorId,
|
||||
accepted: [],
|
||||
rejected: [.marketing],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: nil
|
||||
)
|
||||
XCTAssertTrue(state.isDenied(.marketing))
|
||||
}
|
||||
|
||||
// MARK: - acceptingAll()
|
||||
|
||||
func test_acceptingAll_grantsAllOptionalCategories() {
|
||||
let state = ConsentState(visitorId: visitorId)
|
||||
let accepted = state.acceptingAll()
|
||||
|
||||
let expected = Set(ConsentCategory.allCases.filter { $0.requiresConsent })
|
||||
XCTAssertEqual(accepted.accepted, expected)
|
||||
XCTAssertTrue(accepted.rejected.isEmpty)
|
||||
}
|
||||
|
||||
func test_acceptingAll_setsConsentedAt() {
|
||||
let before = Date()
|
||||
let state = ConsentState(visitorId: visitorId).acceptingAll()
|
||||
XCTAssertNotNil(state.consentedAt)
|
||||
XCTAssertGreaterThanOrEqual(state.consentedAt!, before)
|
||||
}
|
||||
|
||||
func test_acceptingAll_preservesVisitorId() {
|
||||
let state = ConsentState(visitorId: visitorId).acceptingAll()
|
||||
XCTAssertEqual(state.visitorId, visitorId)
|
||||
}
|
||||
|
||||
func test_acceptingAll_hasInteracted() {
|
||||
let state = ConsentState(visitorId: visitorId).acceptingAll()
|
||||
XCTAssertTrue(state.hasInteracted)
|
||||
}
|
||||
|
||||
// MARK: - rejectingAll()
|
||||
|
||||
func test_rejectingAll_emptiesAccepted() {
|
||||
let state = ConsentState(visitorId: visitorId)
|
||||
let rejected = state.rejectingAll()
|
||||
XCTAssertTrue(rejected.accepted.isEmpty)
|
||||
}
|
||||
|
||||
func test_rejectingAll_rejectsAllOptionalCategories() {
|
||||
let state = ConsentState(visitorId: visitorId).rejectingAll()
|
||||
let expected = Set(ConsentCategory.allCases.filter { $0.requiresConsent })
|
||||
XCTAssertEqual(state.rejected, expected)
|
||||
}
|
||||
|
||||
func test_rejectingAll_setsConsentedAt() {
|
||||
let state = ConsentState(visitorId: visitorId).rejectingAll()
|
||||
XCTAssertNotNil(state.consentedAt)
|
||||
}
|
||||
|
||||
// MARK: - accepting(categories:)
|
||||
|
||||
func test_acceptingCategories_onlyAcceptsSpecified() {
|
||||
let state = ConsentState(visitorId: visitorId)
|
||||
let result = state.accepting(categories: [.analytics, .functional])
|
||||
|
||||
XCTAssertTrue(result.accepted.contains(.analytics))
|
||||
XCTAssertTrue(result.accepted.contains(.functional))
|
||||
XCTAssertFalse(result.accepted.contains(.marketing))
|
||||
XCTAssertFalse(result.accepted.contains(.personalisation))
|
||||
}
|
||||
|
||||
func test_acceptingCategories_rejectsRemainder() {
|
||||
let state = ConsentState(visitorId: visitorId)
|
||||
let result = state.accepting(categories: [.analytics])
|
||||
|
||||
XCTAssertTrue(result.rejected.contains(.marketing))
|
||||
XCTAssertTrue(result.rejected.contains(.functional))
|
||||
XCTAssertTrue(result.rejected.contains(.personalisation))
|
||||
}
|
||||
|
||||
func test_acceptingCategories_ignoresNecessary() {
|
||||
let state = ConsentState(visitorId: visitorId)
|
||||
// Passing .necessary should not land in accepted/rejected sets
|
||||
let result = state.accepting(categories: [.necessary])
|
||||
XCTAssertFalse(result.accepted.contains(.necessary))
|
||||
}
|
||||
|
||||
func test_acceptingEmptySet_rejectsAll() {
|
||||
let state = ConsentState(visitorId: visitorId)
|
||||
let result = state.accepting(categories: [])
|
||||
XCTAssertTrue(result.accepted.isEmpty)
|
||||
let expectedRejected = Set(ConsentCategory.allCases.filter { $0.requiresConsent })
|
||||
XCTAssertEqual(result.rejected, expectedRejected)
|
||||
}
|
||||
|
||||
// MARK: - Codable
|
||||
|
||||
func test_state_roundTripsViaJSON() throws {
|
||||
let original = ConsentState(
|
||||
visitorId: visitorId,
|
||||
accepted: [.analytics, .functional],
|
||||
rejected: [.marketing],
|
||||
consentedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
bannerVersion: "v2"
|
||||
)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
|
||||
let data = try encoder.encode(original)
|
||||
let decoded = try decoder.decode(ConsentState.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.visitorId, original.visitorId)
|
||||
XCTAssertEqual(decoded.accepted, original.accepted)
|
||||
XCTAssertEqual(decoded.rejected, original.rejected)
|
||||
XCTAssertEqual(decoded.bannerVersion, original.bannerVersion)
|
||||
// Date round-trip: allow 1-second tolerance for ISO8601 sub-second truncation
|
||||
XCTAssertEqual(
|
||||
decoded.consentedAt!.timeIntervalSince1970,
|
||||
original.consentedAt!.timeIntervalSince1970,
|
||||
accuracy: 1.0
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Equatable
|
||||
|
||||
func test_twoStatesWithSameValues_areEqual() {
|
||||
let date = Date(timeIntervalSince1970: 1_000_000)
|
||||
let a = ConsentState(visitorId: visitorId, accepted: [.analytics], rejected: [], consentedAt: date, bannerVersion: "v1")
|
||||
let b = ConsentState(visitorId: visitorId, accepted: [.analytics], rejected: [], consentedAt: date, bannerVersion: "v1")
|
||||
XCTAssertEqual(a, b)
|
||||
}
|
||||
|
||||
func test_twoStatesWithDifferentAccepted_areNotEqual() {
|
||||
let date = Date(timeIntervalSince1970: 1_000_000)
|
||||
let a = ConsentState(visitorId: visitorId, accepted: [.analytics], rejected: [], consentedAt: date, bannerVersion: nil)
|
||||
let b = ConsentState(visitorId: visitorId, accepted: [.marketing], rejected: [], consentedAt: date, bannerVersion: nil)
|
||||
XCTAssertNotEqual(a, b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import XCTest
|
||||
@testable import ConsentOSCore
|
||||
|
||||
final class ConsentStorageTests: XCTestCase {
|
||||
|
||||
// Use a unique suite per test run to avoid state bleed
|
||||
private var storage: ConsentStorage!
|
||||
private let suiteName = "com.cmp.tests.\(UUID().uuidString)"
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
storage = ConsentStorage(suiteName: suiteName)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Clean up the UserDefaults suite after each test
|
||||
UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Consent State
|
||||
|
||||
func test_loadState_returnsNil_whenNothingStored() {
|
||||
XCTAssertNil(storage.loadState())
|
||||
}
|
||||
|
||||
func test_saveAndLoadState_roundTrips() {
|
||||
let state = ConsentState(
|
||||
visitorId: "visitor-abc",
|
||||
accepted: [.analytics, .functional],
|
||||
rejected: [.marketing],
|
||||
consentedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
bannerVersion: "v3"
|
||||
)
|
||||
|
||||
storage.saveState(state)
|
||||
let loaded = storage.loadState()
|
||||
|
||||
XCTAssertNotNil(loaded)
|
||||
XCTAssertEqual(loaded?.visitorId, state.visitorId)
|
||||
XCTAssertEqual(loaded?.accepted, state.accepted)
|
||||
XCTAssertEqual(loaded?.rejected, state.rejected)
|
||||
XCTAssertEqual(loaded?.bannerVersion, state.bannerVersion)
|
||||
}
|
||||
|
||||
func test_clearState_removesStoredState() {
|
||||
let state = ConsentState(visitorId: "test-visitor")
|
||||
storage.saveState(state)
|
||||
storage.clearState()
|
||||
XCTAssertNil(storage.loadState())
|
||||
}
|
||||
|
||||
func test_saveState_overwritesPreviousState() {
|
||||
let state1 = ConsentState(visitorId: "visitor-1")
|
||||
let state2 = ConsentState(
|
||||
visitorId: "visitor-1",
|
||||
accepted: [.analytics],
|
||||
rejected: [],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: "v2"
|
||||
)
|
||||
|
||||
storage.saveState(state1)
|
||||
storage.saveState(state2)
|
||||
|
||||
let loaded = storage.loadState()
|
||||
XCTAssertEqual(loaded?.accepted, [.analytics])
|
||||
}
|
||||
|
||||
// MARK: - Cached Config
|
||||
|
||||
func test_loadCachedConfig_returnsNil_whenNothingStored() {
|
||||
XCTAssertNil(storage.loadCachedConfig())
|
||||
}
|
||||
|
||||
func test_saveAndLoadCachedConfig_roundTrips() {
|
||||
let config = makeSampleConfig()
|
||||
let cached = CachedConfig(config: config, fetchedAt: Date())
|
||||
|
||||
storage.saveCachedConfig(cached)
|
||||
let loaded = storage.loadCachedConfig()
|
||||
|
||||
XCTAssertNotNil(loaded)
|
||||
XCTAssertEqual(loaded?.config.siteId, config.siteId)
|
||||
XCTAssertEqual(loaded?.config.bannerVersion, config.bannerVersion)
|
||||
}
|
||||
|
||||
func test_clearCachedConfig_removesStoredConfig() {
|
||||
let config = makeSampleConfig()
|
||||
let cached = CachedConfig(config: config, fetchedAt: Date())
|
||||
storage.saveCachedConfig(cached)
|
||||
storage.clearCachedConfig()
|
||||
XCTAssertNil(storage.loadCachedConfig())
|
||||
}
|
||||
|
||||
// MARK: - Cache TTL
|
||||
|
||||
func test_cachedConfig_isNotExpired_whenFetchedJustNow() {
|
||||
let cached = CachedConfig(config: makeSampleConfig(), fetchedAt: Date())
|
||||
XCTAssertFalse(cached.isExpired)
|
||||
}
|
||||
|
||||
func test_cachedConfig_isExpired_whenFetchedOverTTLAgo() {
|
||||
let pastDate = Date(timeIntervalSinceNow: -(CachedConfig.ttl + 1))
|
||||
let cached = CachedConfig(config: makeSampleConfig(), fetchedAt: pastDate)
|
||||
XCTAssertTrue(cached.isExpired)
|
||||
}
|
||||
|
||||
func test_cachedConfig_isNotExpired_whenFetchedJustBeforeTTL() {
|
||||
let almostExpired = Date(timeIntervalSinceNow: -(CachedConfig.ttl - 1))
|
||||
let cached = CachedConfig(config: makeSampleConfig(), fetchedAt: almostExpired)
|
||||
XCTAssertFalse(cached.isExpired)
|
||||
}
|
||||
|
||||
// MARK: - Visitor ID
|
||||
|
||||
func test_visitorId_isGeneratedAndPersisted() {
|
||||
let id1 = storage.visitorId()
|
||||
let id2 = storage.visitorId()
|
||||
XCTAssertEqual(id1, id2)
|
||||
XCTAssertFalse(id1.isEmpty)
|
||||
}
|
||||
|
||||
func test_visitorId_isAValidUUID() {
|
||||
let id = storage.visitorId()
|
||||
XCTAssertNotNil(UUID(uuidString: id), "Visitor ID should be a valid UUID")
|
||||
}
|
||||
|
||||
func test_visitorId_isDifferentAcrossFreshInstances() {
|
||||
let suite2 = "com.cmp.tests.\(UUID().uuidString)"
|
||||
let storage2 = ConsentStorage(suiteName: suite2)
|
||||
defer {
|
||||
UserDefaults(suiteName: suite2)?.removePersistentDomain(forName: suite2)
|
||||
}
|
||||
|
||||
let id1 = storage.visitorId()
|
||||
let id2 = storage2.visitorId()
|
||||
XCTAssertNotEqual(id1, id2)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeSampleConfig() -> ConsentConfig {
|
||||
ConsentConfig(
|
||||
siteId: "site-test-001",
|
||||
siteName: "Test Site",
|
||||
blockingMode: .optIn,
|
||||
consentExpiryDays: 365,
|
||||
bannerVersion: "v1",
|
||||
bannerConfig: ConsentConfig.BannerConfig(),
|
||||
categories: [
|
||||
ConsentConfig.CategoryConfig(
|
||||
key: "necessary",
|
||||
enabled: true,
|
||||
displayName: nil,
|
||||
description: nil
|
||||
),
|
||||
ConsentConfig.CategoryConfig(
|
||||
key: "analytics",
|
||||
enabled: true,
|
||||
displayName: nil,
|
||||
description: nil
|
||||
)
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import XCTest
|
||||
@testable import ConsentOSCore
|
||||
|
||||
final class TCFStringEncoderTests: XCTestCase {
|
||||
|
||||
// MARK: - Encoding Returns nil Before Interaction
|
||||
|
||||
func test_encode_returnsNil_whenStateHasNoInteraction() {
|
||||
let state = ConsentState(visitorId: "v1") // consentedAt is nil
|
||||
let config = makeSampleConfig()
|
||||
XCTAssertNil(TCFStringEncoder.encode(state: state, config: config))
|
||||
}
|
||||
|
||||
// MARK: - Encoding Returns a Non-Empty String After Interaction
|
||||
|
||||
func test_encode_returnsNonEmptyString_afterAcceptAll() {
|
||||
let state = ConsentState(visitorId: "v1").acceptingAll()
|
||||
let config = makeSampleConfig()
|
||||
let result = TCFStringEncoder.encode(state: state, config: config)
|
||||
XCTAssertNotNil(result)
|
||||
XCTAssertFalse(result!.isEmpty)
|
||||
}
|
||||
|
||||
func test_encode_returnsNonEmptyString_afterRejectAll() {
|
||||
let state = ConsentState(visitorId: "v1").rejectingAll()
|
||||
let config = makeSampleConfig()
|
||||
let result = TCFStringEncoder.encode(state: state, config: config)
|
||||
XCTAssertNotNil(result)
|
||||
XCTAssertFalse(result!.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Base64url Format
|
||||
|
||||
func test_encode_producesValidBase64url() {
|
||||
let state = ConsentState(visitorId: "v1").acceptingAll()
|
||||
let config = makeSampleConfig()
|
||||
let tcString = TCFStringEncoder.encode(state: state, config: config)!
|
||||
|
||||
// Base64url must not contain standard Base64 characters that were replaced
|
||||
XCTAssertFalse(tcString.contains("+"), "TC string must not contain '+'")
|
||||
XCTAssertFalse(tcString.contains("/"), "TC string must not contain '/'")
|
||||
XCTAssertFalse(tcString.contains("="), "TC string must not contain padding '='")
|
||||
}
|
||||
|
||||
func test_encode_producesDecodableBase64() {
|
||||
let state = ConsentState(visitorId: "v1").acceptingAll()
|
||||
let config = makeSampleConfig()
|
||||
let tcString = TCFStringEncoder.encode(state: state, config: config)!
|
||||
|
||||
// Convert back to standard Base64 for decoding
|
||||
var base64 = tcString
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
// Pad to multiple of 4
|
||||
let remainder = base64.count % 4
|
||||
if remainder != 0 {
|
||||
base64 += String(repeating: "=", count: 4 - remainder)
|
||||
}
|
||||
|
||||
let data = Data(base64Encoded: base64)
|
||||
XCTAssertNotNil(data, "TC string should be valid Base64")
|
||||
XCTAssertGreaterThan(data!.count, 0)
|
||||
}
|
||||
|
||||
// MARK: - Determinism
|
||||
|
||||
func test_encode_producesConsistentOutput_forSameInput() {
|
||||
// The encoder uses a fixed timestamp, so two calls with the same state
|
||||
// should produce strings of the same length (timestamps differ slightly).
|
||||
let consentDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let state = ConsentState(
|
||||
visitorId: "v1",
|
||||
accepted: [.analytics, .marketing],
|
||||
rejected: [.functional, .personalisation],
|
||||
consentedAt: consentDate,
|
||||
bannerVersion: "v1"
|
||||
)
|
||||
let config = makeSampleConfig()
|
||||
|
||||
let result1 = TCFStringEncoder.encode(state: state, config: config)
|
||||
let result2 = TCFStringEncoder.encode(state: state, config: config)
|
||||
|
||||
XCTAssertEqual(result1, result2)
|
||||
}
|
||||
|
||||
// MARK: - Different States Produce Different Strings
|
||||
|
||||
func test_encode_producesDistinctStrings_forAcceptAllVsRejectAll() {
|
||||
let consentDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let acceptedState = ConsentState(
|
||||
visitorId: "v1",
|
||||
accepted: Set(ConsentCategory.allCases.filter { $0.requiresConsent }),
|
||||
rejected: [],
|
||||
consentedAt: consentDate,
|
||||
bannerVersion: nil
|
||||
)
|
||||
let rejectedState = ConsentState(
|
||||
visitorId: "v1",
|
||||
accepted: [],
|
||||
rejected: Set(ConsentCategory.allCases.filter { $0.requiresConsent }),
|
||||
consentedAt: consentDate,
|
||||
bannerVersion: nil
|
||||
)
|
||||
let config = makeSampleConfig()
|
||||
|
||||
let tcAccepted = TCFStringEncoder.encode(state: acceptedState, config: config)
|
||||
let tcRejected = TCFStringEncoder.encode(state: rejectedState, config: config)
|
||||
|
||||
XCTAssertNotEqual(tcAccepted, tcRejected)
|
||||
}
|
||||
|
||||
// MARK: - Minimum Length
|
||||
|
||||
func test_encode_producesStringOfReasonableLength() {
|
||||
let state = ConsentState(visitorId: "v1").acceptingAll()
|
||||
let config = makeSampleConfig()
|
||||
let tcString = TCFStringEncoder.encode(state: state, config: config)!
|
||||
|
||||
// A valid core TC string serialises to at least ~20 bytes before Base64url encoding
|
||||
// (the first 6 bits alone carry the version). 28 chars is a reasonable lower bound.
|
||||
XCTAssertGreaterThan(tcString.count, 28, "TC string appears too short")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeSampleConfig() -> ConsentConfig {
|
||||
ConsentConfig(
|
||||
siteId: "site-001",
|
||||
siteName: "Test Site",
|
||||
blockingMode: .optIn,
|
||||
consentExpiryDays: 365,
|
||||
bannerVersion: "v1",
|
||||
bannerConfig: ConsentConfig.BannerConfig(),
|
||||
categories: []
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user