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