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:
James Cottrill
2026-04-13 14:20:15 +00:00
commit fbf26453f2
341 changed files with 62807 additions and 0 deletions

View 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")
}

View 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

View 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

View File

@@ -0,0 +1,2 @@
// Settings for the CMP Consent Android SDK module.
rootProject.name = "consentos"

View 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>

View File

@@ -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) }
}
}

View File

@@ -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",
)
}
}

View File

@@ -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
}

View File

@@ -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))
}
}

View File

@@ -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(),
)
}
}

View File

@@ -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,
)
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -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))
}
}
}

View File

@@ -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.
}
}

View File

@@ -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)
}

View File

@@ -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 }
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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(),
)
}

View File

@@ -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(),
)
}

View 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

View 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"
)
]
)

View 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)"
}
}

View 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
}
}

View 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
}
}

View 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.

View 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
)
}
}

View 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
}
}

View 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
}
}

View 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)
}
}

View 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

View 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

View File

@@ -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

View 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

View File

@@ -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: []
)
}
}

View File

@@ -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)
}
}

View 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: []
)
}
}

View File

@@ -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)
}
}

View File

@@ -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
)
]
)
}
}

View File

@@ -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: []
)
}
}