feat: initial public release
ConsentOS — a privacy-first cookie consent management platform. Self-hosted, source-available alternative to OneTrust, Cookiebot, and CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant architecture with role-based access, configuration cascade (system → org → group → site → region), dark-pattern detection in the scanner, and a tamper-evident consent record audit trail. This is the initial public release. Prior development history is retained internally. See README.md for the feature list, architecture overview, and quick-start instructions. Licensed under the Elastic Licence 2.0 — self-host freely; do not resell as a managed service.
This commit is contained in:
84
sdks/android/consentos/build.gradle.kts
Normal file
84
sdks/android/consentos/build.gradle.kts
Normal file
@@ -0,0 +1,84 @@
|
||||
plugins {
|
||||
id("com.android.library") version "8.3.0"
|
||||
id("org.jetbrains.kotlin.android") version "1.9.23"
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.23"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.consentos"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26 // Android 8.0 (Oreo) — required for EncryptedSharedPreferences
|
||||
targetSdk = 34
|
||||
|
||||
// Consumer ProGuard rules are included in the AAR and applied to the consuming app.
|
||||
consumerProguardFiles("proguard-rules.pro")
|
||||
|
||||
// SDK version available at runtime for User-Agent and logging.
|
||||
buildConfigField("String", "SDK_VERSION", "\"${project.property("VERSION_NAME")}\"")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false // Library modules are not minified; the consuming app handles this.
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
freeCompilerArgs += listOf(
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
)
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Kotlin coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0")
|
||||
|
||||
// Kotlinx serialization (JSON)
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
|
||||
|
||||
// AndroidX — EncryptedSharedPreferences requires security-crypto
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// AndroidX annotations (@VisibleForTesting, @ColorInt, etc.)
|
||||
implementation("androidx.annotation:annotation:1.7.1")
|
||||
|
||||
// Material Components — for BottomSheetDialogFragment and theming
|
||||
implementation("com.google.android.material:material:1.11.0")
|
||||
|
||||
// AndroidX Fragment (BottomSheetDialogFragment)
|
||||
implementation("androidx.fragment:fragment-ktx:1.6.2")
|
||||
|
||||
// AppCompat — for ConsentBannerActivity base class and translucent theme
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
|
||||
// Optional: Firebase / GCM bridge (compileOnly so apps without Firebase still compile)
|
||||
compileOnly("com.google.firebase:firebase-analytics-ktx:21.6.1")
|
||||
|
||||
// Unit test dependencies — plain JVM, no instrumentation required
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.mockito:mockito-core:5.11.0")
|
||||
testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
|
||||
testImplementation("org.robolectric:robolectric:4.12.1")
|
||||
testImplementation("androidx.test:core:1.5.0")
|
||||
}
|
||||
14
sdks/android/consentos/gradle.properties
Normal file
14
sdks/android/consentos/gradle.properties
Normal file
@@ -0,0 +1,14 @@
|
||||
# Gradle properties for the CMP Consent Android SDK.
|
||||
|
||||
# SDK version published to Maven
|
||||
VERSION_NAME=1.0.0
|
||||
GROUP_ID=com.consentos
|
||||
ARTIFACT_ID=consentos
|
||||
|
||||
# Android build properties
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=false
|
||||
kotlin.code.style=official
|
||||
|
||||
# Increase heap for builds with many source files
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
98
sdks/android/consentos/proguard-rules.pro
vendored
Normal file
98
sdks/android/consentos/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
# ProGuard / R8 consumer rules for the CMP Consent Android SDK.
|
||||
#
|
||||
# These rules are included in the AAR and applied automatically to the consuming
|
||||
# app's build. They prevent R8 from stripping or renaming classes that must be
|
||||
# preserved for correct SDK operation.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CMP Consent SDK — keep all public API classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Main SDK entry point and public interfaces
|
||||
-keep class com.consentos.ConsentOS { *; }
|
||||
-keep interface com.consentos.ConsentOSListener { *; }
|
||||
|
||||
# Data models serialised to/from JSON via kotlinx.serialization
|
||||
-keep class com.consentos.ConsentState { *; }
|
||||
-keep class com.consentos.ConsentConfig { *; }
|
||||
-keep class com.consentos.ConsentConfig$** { *; }
|
||||
-keep class com.consentos.CachedConfig { *; }
|
||||
-keep class com.consentos.ConsentPayload { *; }
|
||||
|
||||
# Enums used in public API (R8 may inline or remove them if not kept explicitly)
|
||||
-keep enum com.consentos.ConsentCategory { *; }
|
||||
-keep enum com.consentos.GCMConsentType { *; }
|
||||
-keep enum com.consentos.GCMConsentStatus { *; }
|
||||
|
||||
# Storage and API protocols — allow apps to provide custom implementations
|
||||
-keep interface com.consentos.ConsentStorageProtocol { *; }
|
||||
-keep interface com.consentos.ConsentAPIProtocol { *; }
|
||||
-keep interface com.consentos.GCMAnalyticsProvider { *; }
|
||||
-keep class com.consentos.ConsentStorage { *; }
|
||||
-keep class com.consentos.ConsentAPI { *; }
|
||||
-keep class com.consentos.GCMBridge { *; }
|
||||
-keep class com.consentos.NoOpGCMAnalyticsProvider { *; }
|
||||
-keep class com.consentos.TCFStringEncoder { *; }
|
||||
|
||||
# Exceptions — keep for diagnostics / catch blocks in consuming apps
|
||||
-keep class com.consentos.ConsentAPIException { *; }
|
||||
-keep class com.consentos.ConsentAPIException$** { *; }
|
||||
|
||||
# UI components registered in the manifest
|
||||
-keep class com.consentos.ui.ConsentBannerActivity { *; }
|
||||
-keep class com.consentos.ui.ConsentBottomSheet { *; }
|
||||
-keep class com.consentos.ui.BannerTheme { *; }
|
||||
|
||||
# Broadcast action constant
|
||||
-keep class com.consentos.ConsentOSKt { *; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# kotlinx.serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Serialisation uses reflection to access serialisers; prevent R8 removing them.
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.AnnotationsKt
|
||||
|
||||
-keepclassmembers class kotlinx.serialization.json.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class kotlinx.serialization.json.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# Keep all classes annotated with @Serializable
|
||||
-keep @kotlinx.serialization.Serializable class * { *; }
|
||||
|
||||
# Serialiser lookup table generated by the Kotlin compiler
|
||||
-keep class **$$serializer { *; }
|
||||
-keepclassmembers class ** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class ** {
|
||||
public static ** INSTANCE;
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kotlin coroutines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
|
||||
-keepclassmembernames class kotlinx.** {
|
||||
volatile <fields>;
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AndroidX Security (EncryptedSharedPreferences)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
-keep class androidx.security.crypto.** { *; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Suppress common noise warnings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
-dontwarn java.lang.instrument.ClassFileTransformer
|
||||
-dontwarn sun.misc.SignalHandler
|
||||
2
sdks/android/consentos/settings.gradle.kts
Normal file
2
sdks/android/consentos/settings.gradle.kts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Settings for the CMP Consent Android SDK module.
|
||||
rootProject.name = "consentos"
|
||||
16
sdks/android/consentos/src/main/AndroidManifest.xml
Normal file
16
sdks/android/consentos/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Required for the SDK to fetch site configuration and post consent records. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application>
|
||||
<!-- ConsentBannerActivity is declared here so host apps need not add it manually. -->
|
||||
<activity
|
||||
android:name=".ui.ConsentBannerActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.AppCompat.Translucent"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.consentos
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
// =============================================================================
|
||||
// Protocol (Interface)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Abstracts the network layer for testability.
|
||||
*
|
||||
* Implementations must be safe to call from any coroutine context.
|
||||
*/
|
||||
interface ConsentAPIProtocol {
|
||||
/**
|
||||
* Fetches the effective site configuration from the API.
|
||||
*
|
||||
* @param siteId The site identifier registered in the CMP dashboard.
|
||||
* @throws ConsentAPIException on network or server errors.
|
||||
*/
|
||||
suspend fun fetchConfig(siteId: String): ConsentConfig
|
||||
|
||||
/**
|
||||
* Posts a consent record to the server.
|
||||
*
|
||||
* @param payload The consent event payload.
|
||||
* @throws ConsentAPIException on network or server errors.
|
||||
*/
|
||||
suspend fun postConsent(payload: ConsentPayload)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Errors
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Errors that can be thrown by the CMP API client.
|
||||
*/
|
||||
sealed class ConsentAPIException(message: String, cause: Throwable? = null) :
|
||||
Exception(message, cause) {
|
||||
|
||||
/** The constructed API URL is malformed. */
|
||||
class InvalidURL(url: String) :
|
||||
ConsentAPIException("The constructed API URL is invalid: $url")
|
||||
|
||||
/** The server returned a non-2xx HTTP status code. */
|
||||
class UnexpectedStatusCode(val code: Int) :
|
||||
ConsentAPIException("The server returned an unexpected HTTP status code: $code.")
|
||||
|
||||
/** The server response could not be decoded. */
|
||||
class DecodingFailure(cause: Throwable) :
|
||||
ConsentAPIException("Failed to decode the server response: ${cause.message}", cause)
|
||||
|
||||
/** A network I/O error occurred (e.g. no connectivity). */
|
||||
class NetworkFailure(cause: Throwable) :
|
||||
ConsentAPIException("A network error occurred: ${cause.message}", cause)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Consent Payload
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* The request body sent when recording a consent event.
|
||||
*/
|
||||
@Serializable
|
||||
data class ConsentPayload(
|
||||
@SerialName("site_id") val siteId: String,
|
||||
@SerialName("visitor_id") val visitorId: String,
|
||||
val platform: String = "android",
|
||||
val accepted: List<String>,
|
||||
val rejected: List<String>,
|
||||
@SerialName("consented_at") val consentedAt: String, // ISO-8601 UTC
|
||||
@SerialName("banner_version") val bannerVersion: String? = null,
|
||||
@SerialName("user_agent") val userAgent: String? = null,
|
||||
@SerialName("tc_string") val tcString: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Creates a [ConsentPayload] from a [ConsentState].
|
||||
*
|
||||
* @param siteId The site identifier.
|
||||
* @param state The consent state to serialise.
|
||||
* @param tcString Optional TC string generated by [TCFStringEncoder].
|
||||
*/
|
||||
fun from(
|
||||
siteId: String,
|
||||
state: ConsentState,
|
||||
tcString: String? = null,
|
||||
): ConsentPayload? {
|
||||
val consentedAtMs = state.consentedAtMs ?: return null
|
||||
return ConsentPayload(
|
||||
siteId = siteId,
|
||||
visitorId = state.visitorId,
|
||||
accepted = state.accepted.map { it.value },
|
||||
rejected = state.rejected.map { it.value },
|
||||
consentedAt = formatIso8601(consentedAtMs),
|
||||
bannerVersion = state.bannerVersion,
|
||||
tcString = tcString,
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatIso8601(epochMs: Long): String {
|
||||
val sdf = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US)
|
||||
sdf.timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
return sdf.format(java.util.Date(epochMs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Live Implementation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* HttpURLConnection-backed API client that communicates with the CMP API.
|
||||
*
|
||||
* Uses [kotlinx.coroutines.Dispatchers.IO] for all network calls so they are safe to
|
||||
* invoke from any coroutine context.
|
||||
*
|
||||
* @param apiBase The base URL of the CMP API (e.g. `https://api.example.com`).
|
||||
*/
|
||||
class ConsentAPI(private val apiBase: String) : ConsentAPIProtocol {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val userAgent = "ConsentOS-Android/${BuildConfig.SDK_VERSION} (Kotlin)"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ConsentAPIProtocol
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
override suspend fun fetchConfig(siteId: String): ConsentConfig =
|
||||
withContext(Dispatchers.IO) {
|
||||
val urlStr = "$apiBase/api/v1/config/sites/$siteId/effective"
|
||||
val connection = openConnection(urlStr)
|
||||
try {
|
||||
connection.requestMethod = "GET"
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", userAgent)
|
||||
connection.connect()
|
||||
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) throw ConsentAPIException.UnexpectedStatusCode(code)
|
||||
|
||||
val body = connection.inputStream.bufferedReader().readText()
|
||||
runCatching { json.decodeFromString<ConsentConfig>(body) }
|
||||
.getOrElse { throw ConsentAPIException.DecodingFailure(it) }
|
||||
} catch (e: ConsentAPIException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
throw ConsentAPIException.NetworkFailure(e)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun postConsent(payload: ConsentPayload): Unit =
|
||||
withContext(Dispatchers.IO) {
|
||||
val urlStr = "$apiBase/api/v1/consent"
|
||||
val connection = openConnection(urlStr)
|
||||
try {
|
||||
connection.requestMethod = "POST"
|
||||
connection.setRequestProperty("Content-Type", "application/json")
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", userAgent)
|
||||
connection.doOutput = true
|
||||
|
||||
val body = json.encodeToString(payload)
|
||||
connection.outputStream.bufferedWriter().use { it.write(body) }
|
||||
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) throw ConsentAPIException.UnexpectedStatusCode(code)
|
||||
} catch (e: ConsentAPIException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
throw ConsentAPIException.NetworkFailure(e)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Throws(ConsentAPIException.InvalidURL::class)
|
||||
private fun openConnection(urlStr: String): HttpURLConnection {
|
||||
return runCatching { URL(urlStr).openConnection() as HttpURLConnection }
|
||||
.getOrElse { throw ConsentAPIException.InvalidURL(urlStr) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.consentos
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
|
||||
/**
|
||||
* Consent categories matching the CMP platform's taxonomy.
|
||||
*
|
||||
* Each category maps to IAB TCF v2.2 purposes and Google Consent Mode v2 consent types.
|
||||
* The [NECESSARY] category is always granted and cannot be revoked by the user.
|
||||
*
|
||||
* Serialised using the [value] string (e.g. `"analytics"`) rather than the enum name,
|
||||
* to remain consistent with the web platform's cookie structure.
|
||||
*/
|
||||
@Serializable(with = ConsentCategorySerializer::class)
|
||||
enum class ConsentCategory(
|
||||
/** The raw string value used in API payloads and persistent storage. */
|
||||
val value: String,
|
||||
) {
|
||||
/** Strictly necessary cookies — always allowed, no consent required. */
|
||||
NECESSARY("necessary"),
|
||||
|
||||
/** Functional / preference cookies (e.g. language, saved settings). */
|
||||
FUNCTIONAL("functional"),
|
||||
|
||||
/** Analytics / statistics cookies (e.g. page views, session data). */
|
||||
ANALYTICS("analytics"),
|
||||
|
||||
/** Marketing / advertising cookies (e.g. retargeting, personalised ads). */
|
||||
MARKETING("marketing"),
|
||||
|
||||
/** Personalisation cookies (e.g. content recommendations). */
|
||||
PERSONALISATION("personalisation");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TCF Purpose Mappings
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* IAB TCF v2.2 purpose IDs associated with this category.
|
||||
*
|
||||
* Returns an empty list for [NECESSARY], which does not require consent purposes.
|
||||
*/
|
||||
val tcfPurposeIds: List<Int>
|
||||
get() = when (this) {
|
||||
NECESSARY -> emptyList()
|
||||
FUNCTIONAL -> listOf(1) // Store and/or access information on a device
|
||||
ANALYTICS -> listOf(7, 8, 9, 10) // Measurement, market research, product development
|
||||
MARKETING -> listOf(2, 3, 4) // Select basic/personalised ads, create ad profile
|
||||
PERSONALISATION -> listOf(5, 6) // Create content profile, select personalised content
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Google Consent Mode v2 Mappings
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Google Consent Mode v2 consent type string for this category.
|
||||
*
|
||||
* Returns `null` for categories that do not have a direct GCM mapping.
|
||||
*/
|
||||
val gcmConsentType: String?
|
||||
get() = when (this) {
|
||||
NECESSARY -> null
|
||||
FUNCTIONAL -> "functionality_storage"
|
||||
ANALYTICS -> "analytics_storage"
|
||||
MARKETING -> "ad_storage"
|
||||
PERSONALISATION -> "personalization_storage"
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Display
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Human-readable display name for the category (British English). */
|
||||
val displayName: String
|
||||
get() = when (this) {
|
||||
NECESSARY -> "Strictly Necessary"
|
||||
FUNCTIONAL -> "Functional"
|
||||
ANALYTICS -> "Analytics"
|
||||
MARKETING -> "Marketing"
|
||||
PERSONALISATION -> "Personalisation"
|
||||
}
|
||||
|
||||
/** Brief description of the category for banner display. */
|
||||
val displayDescription: String
|
||||
get() = when (this) {
|
||||
NECESSARY -> "Essential for the application to function. These cannot be disabled."
|
||||
FUNCTIONAL -> "Enable enhanced functionality such as remembering your preferences."
|
||||
ANALYTICS -> "Help us understand how users interact with the application."
|
||||
MARKETING -> "Used to deliver relevant advertisements and track ad campaign performance."
|
||||
PERSONALISATION -> "Allow us to personalise content based on your interests."
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether consent for this category is required before storing data.
|
||||
*
|
||||
* [NECESSARY] is exempt from consent requirements under ePrivacy regulations.
|
||||
*/
|
||||
val requiresConsent: Boolean
|
||||
get() = this != NECESSARY
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Returns the [ConsentCategory] matching [value], or `null` if unrecognised.
|
||||
*/
|
||||
fun fromValue(value: String): ConsentCategory? =
|
||||
entries.firstOrNull { it.value == value }
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Kotlinx Serializer
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Custom kotlinx.serialization serialiser for [ConsentCategory].
|
||||
*
|
||||
* Serialises using the [ConsentCategory.value] string (e.g. `"analytics"`) so that
|
||||
* JSON payloads are consistent with the web platform's cookie structure.
|
||||
*/
|
||||
object ConsentCategorySerializer : KSerializer<ConsentCategory> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("ConsentCategory", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: ConsentCategory) {
|
||||
encoder.encodeString(value.value)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): ConsentCategory {
|
||||
val raw = decoder.decodeString()
|
||||
return ConsentCategory.fromValue(raw)
|
||||
?: throw kotlinx.serialization.SerializationException(
|
||||
"Unknown ConsentCategory value: $raw",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.consentos
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The effective site configuration loaded from the CMP API.
|
||||
*
|
||||
* Maps to the response of `GET {apiBase}/api/v1/config/sites/{siteId}/effective`.
|
||||
* The configuration drives banner display, theming, blocking mode, and consent expiry.
|
||||
*/
|
||||
@Serializable
|
||||
data class ConsentConfig(
|
||||
/** Unique identifier for the site. */
|
||||
@SerialName("site_id")
|
||||
val siteId: String,
|
||||
|
||||
/** Human-readable name for the site. */
|
||||
@SerialName("site_name")
|
||||
val siteName: String,
|
||||
|
||||
/** The blocking mode determining the default consent model. */
|
||||
@SerialName("blocking_mode")
|
||||
val blockingMode: BlockingMode,
|
||||
|
||||
/** Consent validity in days. After expiry the banner is shown again. */
|
||||
@SerialName("consent_expiry_days")
|
||||
val consentExpiryDays: Int,
|
||||
|
||||
/**
|
||||
* The current version of this configuration.
|
||||
* Stored alongside consent records to detect when re-consent is needed.
|
||||
*/
|
||||
@SerialName("banner_version")
|
||||
val bannerVersion: String,
|
||||
|
||||
/** Banner display and theming configuration. */
|
||||
@SerialName("banner_config")
|
||||
val bannerConfig: BannerConfig,
|
||||
|
||||
/** Available categories for this site. */
|
||||
val categories: List<CategoryConfig> = emptyList(),
|
||||
) {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Blocking Mode
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The consent model applied to visitors.
|
||||
*/
|
||||
@Serializable
|
||||
enum class BlockingMode(val value: String) {
|
||||
/** User must opt in before non-essential scripts run (GDPR default). */
|
||||
@SerialName("opt_in")
|
||||
OPT_IN("opt_in"),
|
||||
|
||||
/** Non-essential scripts run by default; user may opt out (CCPA default). */
|
||||
@SerialName("opt_out")
|
||||
OPT_OUT("opt_out"),
|
||||
|
||||
/** Informational notice only; no blocking. */
|
||||
@SerialName("informational")
|
||||
INFORMATIONAL("informational"),
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Banner Configuration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Visual and behavioural configuration for the consent banner.
|
||||
*/
|
||||
@Serializable
|
||||
data class BannerConfig(
|
||||
/** Display mode controlling the banner layout. */
|
||||
@SerialName("display_mode")
|
||||
val displayMode: DisplayMode = DisplayMode.BOTTOM_BANNER,
|
||||
|
||||
/** Primary background colour as a hex string (e.g. `"#FFFFFF"`). */
|
||||
@SerialName("background_color")
|
||||
val backgroundColor: String? = null,
|
||||
|
||||
/** Primary text colour as a hex string. */
|
||||
@SerialName("text_color")
|
||||
val textColor: String? = null,
|
||||
|
||||
/** Accent colour used for buttons and highlights. */
|
||||
@SerialName("accent_color")
|
||||
val accentColor: String? = null,
|
||||
|
||||
/** Text for the "Accept all" button. */
|
||||
@SerialName("accept_button_text")
|
||||
val acceptButtonText: String? = null,
|
||||
|
||||
/** Text for the "Reject all" button. */
|
||||
@SerialName("reject_button_text")
|
||||
val rejectButtonText: String? = null,
|
||||
|
||||
/** Text for the "Manage preferences" button. */
|
||||
@SerialName("manage_button_text")
|
||||
val manageButtonText: String? = null,
|
||||
|
||||
/** The banner title text. */
|
||||
val title: String? = null,
|
||||
|
||||
/** The banner body copy. */
|
||||
val description: String? = null,
|
||||
|
||||
/** URL for the site's privacy policy. */
|
||||
@SerialName("privacy_policy_url")
|
||||
val privacyPolicyUrl: String? = null,
|
||||
) {
|
||||
/** Display mode options for the banner. */
|
||||
@Serializable
|
||||
enum class DisplayMode(val value: String) {
|
||||
@SerialName("overlay")
|
||||
OVERLAY("overlay"),
|
||||
|
||||
@SerialName("bottom_banner")
|
||||
BOTTOM_BANNER("bottom_banner"),
|
||||
|
||||
@SerialName("top_banner")
|
||||
TOP_BANNER("top_banner"),
|
||||
|
||||
@SerialName("corner_popup")
|
||||
CORNER_POPUP("corner_popup"),
|
||||
|
||||
@SerialName("inline")
|
||||
INLINE("inline"),
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Category Configuration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-category configuration as returned by the API.
|
||||
*/
|
||||
@Serializable
|
||||
data class CategoryConfig(
|
||||
/** Machine-readable category key (matches [ConsentCategory.value]). */
|
||||
val key: String,
|
||||
|
||||
/** Whether this category is enabled for this site. */
|
||||
val enabled: Boolean,
|
||||
|
||||
/** Overridden display name (falls back to [ConsentCategory.displayName] if absent). */
|
||||
@SerialName("display_name")
|
||||
val displayName: String? = null,
|
||||
|
||||
/** Overridden description text. */
|
||||
val description: String? = null,
|
||||
) {
|
||||
/** Resolves to the matching [ConsentCategory], if the key is recognised. */
|
||||
val category: ConsentCategory?
|
||||
get() = ConsentCategory.fromValue(key)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Convenience
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Returns only the enabled [ConsentCategory] values for this config. */
|
||||
val enabledCategories: List<ConsentCategory>
|
||||
get() = categories.mapNotNull { cfg ->
|
||||
if (cfg.enabled) cfg.category else null
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cached Config Wrapper
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wraps a [ConsentConfig] with metadata needed for cache invalidation.
|
||||
*/
|
||||
@Serializable
|
||||
data class CachedConfig(
|
||||
val config: ConsentConfig,
|
||||
/** Epoch milliseconds at which the config was fetched. */
|
||||
val fetchedAtMs: Long,
|
||||
) {
|
||||
companion object {
|
||||
/** The cache TTL in milliseconds (10 minutes). */
|
||||
const val TTL_MS: Long = 10 * 60 * 1_000L
|
||||
}
|
||||
|
||||
/** Returns `true` if the cached entry has exceeded the TTL. */
|
||||
val isExpired: Boolean
|
||||
get() = System.currentTimeMillis() - fetchedAtMs > TTL_MS
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package com.consentos
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import com.consentos.ui.ConsentBottomSheet
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// =============================================================================
|
||||
// Listener Interface
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Receives notifications when the user's consent choices change.
|
||||
*
|
||||
* Methods are invoked on the main thread.
|
||||
*/
|
||||
interface ConsentOSListener {
|
||||
/**
|
||||
* Called after consent has been updated.
|
||||
*
|
||||
* @param state The new consent state.
|
||||
*/
|
||||
fun onConsentChanged(state: ConsentState)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Broadcast Actions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Intent action broadcast when consent state changes.
|
||||
*
|
||||
* The updated [ConsentState] is not included in the broadcast — call
|
||||
* [ConsentOS.getConsentStatus] to read the current state.
|
||||
*/
|
||||
const val ACTION_CONSENT_CHANGED = "com.consentos.ACTION_CONSENT_CHANGED"
|
||||
|
||||
// =============================================================================
|
||||
// ConsentOS Singleton
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* The main entry point for the CMP Android SDK.
|
||||
*
|
||||
* Use the singleton [instance] to configure the SDK, display the consent banner,
|
||||
* and query consent status. All public methods are coroutine-safe.
|
||||
*
|
||||
* ```kotlin
|
||||
* // In Application.onCreate()
|
||||
* ConsentOS.instance.configure(
|
||||
* context = applicationContext,
|
||||
* siteId = "my-site-id",
|
||||
* apiBase = "https://api.example.com",
|
||||
* )
|
||||
*
|
||||
* // In your Activity / Fragment
|
||||
* lifecycleScope.launch {
|
||||
* if (ConsentOS.instance.shouldShowBanner()) {
|
||||
* ConsentOS.instance.showBanner(supportFragmentManager)
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
class ConsentOS internal constructor(
|
||||
private var storage: ConsentStorageProtocol? = null,
|
||||
private var api: ConsentAPIProtocol? = null,
|
||||
private var gcmBridge: GCMBridge = GCMBridge(),
|
||||
) {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Singleton
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
companion object {
|
||||
/** The shared SDK instance. Configure this before use. */
|
||||
@JvmField
|
||||
val instance: ConsentOS = ConsentOS()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// State
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** The site ID set during [configure]. */
|
||||
@Volatile
|
||||
var siteId: String? = null
|
||||
@androidx.annotation.VisibleForTesting internal set
|
||||
|
||||
/** Whether the SDK has been configured. */
|
||||
val isConfigured: Boolean get() = siteId != null
|
||||
|
||||
/** The current consent state. `null` until [configure] has been called. */
|
||||
@Volatile
|
||||
var consentState: ConsentState? = null
|
||||
@androidx.annotation.VisibleForTesting internal set
|
||||
|
||||
/** The currently loaded site configuration. `null` until fetched. */
|
||||
@Volatile
|
||||
var siteConfig: ConsentConfig? = null
|
||||
@androidx.annotation.VisibleForTesting internal set
|
||||
|
||||
private val listeners = mutableListOf<ConsentOSListener>()
|
||||
private val listenerLock = Any()
|
||||
|
||||
// Mutex guards mutations to consentState and siteConfig across coroutines.
|
||||
private val stateMutex = Mutex()
|
||||
|
||||
// Application context used for broadcasting intents.
|
||||
@Volatile
|
||||
private var appContext: Context? = null
|
||||
|
||||
// Background scope for config refresh and consent sync operations.
|
||||
private val sdkScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Configures the SDK with a site ID and API base URL.
|
||||
*
|
||||
* Must be called before any other method. Loads the persisted consent state
|
||||
* and fetches the site configuration in the background.
|
||||
*
|
||||
* @param context Application context (used for encrypted storage and broadcasts).
|
||||
* @param siteId The unique identifier for the site (from the CMP dashboard).
|
||||
* @param apiBase Base URL of the CMP API (e.g. `https://api.example.com`).
|
||||
* @param gcmProvider Optional GCM analytics provider. Defaults to no-op.
|
||||
*/
|
||||
fun configure(
|
||||
context: Context,
|
||||
siteId: String,
|
||||
apiBase: String,
|
||||
gcmProvider: GCMAnalyticsProvider? = null,
|
||||
) {
|
||||
this.appContext = context.applicationContext
|
||||
this.siteId = siteId
|
||||
|
||||
if (storage == null) {
|
||||
storage = ConsentStorage(context.applicationContext)
|
||||
}
|
||||
if (api == null) {
|
||||
api = ConsentAPI(apiBase)
|
||||
}
|
||||
if (gcmProvider != null) {
|
||||
gcmBridge = GCMBridge(gcmProvider)
|
||||
}
|
||||
|
||||
// Restore persisted state synchronously so it is immediately available.
|
||||
val store = storage!!
|
||||
val visitorId = store.visitorId()
|
||||
consentState = store.loadState() ?: ConsentState(visitorId = visitorId)
|
||||
|
||||
// Fetch config in the background; apply GCM defaults once available.
|
||||
sdkScope.launch { refreshConfigIfNeeded() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the API client. Useful for testing or custom transports.
|
||||
*/
|
||||
fun setAPI(api: ConsentAPIProtocol) {
|
||||
this.api = api
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Banner Display
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns `true` if the consent banner should be shown to this visitor.
|
||||
*
|
||||
* The banner is required when:
|
||||
* - The user has not yet interacted (no [ConsentState.consentedAtMs]), or
|
||||
* - The stored banner version differs from the current config version, or
|
||||
* - The stored consent is older than the site's configured expiry.
|
||||
*/
|
||||
suspend fun shouldShowBanner(): Boolean {
|
||||
refreshConfigIfNeeded()
|
||||
|
||||
val state = consentState
|
||||
val config = siteConfig
|
||||
|
||||
if (state == null || !state.hasInteracted) return true
|
||||
|
||||
if (config != null && state.consentedAtMs != null) {
|
||||
// Check banner version mismatch — re-consent required after config change.
|
||||
if (state.bannerVersion != config.bannerVersion) return true
|
||||
|
||||
// Check consent expiry.
|
||||
val expiryMs = config.consentExpiryDays * 86_400_000L
|
||||
if (System.currentTimeMillis() - state.consentedAtMs > expiryMs) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Presents the consent banner as a [ConsentBottomSheet] attached to the given
|
||||
* [FragmentManager].
|
||||
*
|
||||
* Safe to call from the main thread. The bottom sheet is shown immediately if the
|
||||
* fragment manager is in a valid state.
|
||||
*
|
||||
* @param fragmentManager The [FragmentManager] from the calling Activity or Fragment.
|
||||
* @param tag Optional back-stack tag. Defaults to `"cmp_consent_banner"`.
|
||||
*/
|
||||
fun showBanner(
|
||||
fragmentManager: FragmentManager,
|
||||
tag: String = "cmp_consent_banner",
|
||||
) {
|
||||
val config = siteConfig
|
||||
val sheet = ConsentBottomSheet.newInstance(config)
|
||||
sheet.show(fragmentManager, tag)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Consent Actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Accepts all non-necessary consent categories.
|
||||
*
|
||||
* Updates local state, syncs to the server, and fires GCM signals.
|
||||
*/
|
||||
suspend fun acceptAll() {
|
||||
applyConsent { it.acceptingAll() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects all non-necessary consent categories.
|
||||
*/
|
||||
suspend fun rejectAll() {
|
||||
applyConsent { it.rejectingAll() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts only the specified [categories] (and rejects all others).
|
||||
*
|
||||
* @param categories The set of categories to accept.
|
||||
*/
|
||||
suspend fun acceptCategories(categories: List<ConsentCategory>) {
|
||||
applyConsent { it.accepting(categories.toSet()) }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Query
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the consent status for a specific [category].
|
||||
*
|
||||
* @return `true` if consent has been granted, `false` if denied or not yet given.
|
||||
*/
|
||||
fun getConsentStatus(category: ConsentCategory): Boolean =
|
||||
consentState?.isGranted(category) ?: (category == ConsentCategory.NECESSARY)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// User Identity
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Associates the current visitor with a verified user identity.
|
||||
*
|
||||
* The JWT is sent alongside subsequent consent records for server-side correlation
|
||||
* with authenticated users.
|
||||
*
|
||||
* @param jwt A signed JWT issued by the host application's auth system.
|
||||
*/
|
||||
fun identifyUser(jwt: String) {
|
||||
// Store JWT in plaintext preferences for inclusion in future consent payloads.
|
||||
// In a production implementation this would also trigger a re-sync of the consent record.
|
||||
appContext?.getSharedPreferences("com_cmp_consent_identity", Context.MODE_PRIVATE)
|
||||
?.edit()
|
||||
?.putString("user_jwt", jwt)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Listener Registration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Registers a [ConsentOSListener] to be notified of consent changes.
|
||||
*
|
||||
* Listeners are held strongly; call [removeConsentListener] to avoid leaks.
|
||||
*/
|
||||
fun addConsentListener(listener: ConsentOSListener) {
|
||||
synchronized(listenerLock) { listeners.add(listener) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a previously registered [ConsentOSListener].
|
||||
*/
|
||||
fun removeConsentListener(listener: ConsentOSListener) {
|
||||
synchronized(listenerLock) { listeners.remove(listener) }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal: Config Refresh
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
internal suspend fun refreshConfigIfNeeded(): ConsentConfig? {
|
||||
// Return cached config if it is still valid.
|
||||
storage?.loadCachedConfig()?.takeUnless { it.isExpired }?.let { cached ->
|
||||
stateMutex.withLock { siteConfig = cached.config }
|
||||
return cached.config
|
||||
}
|
||||
|
||||
val currentSiteId = siteId ?: return null
|
||||
val currentApi = api ?: return null
|
||||
|
||||
return runCatching {
|
||||
val config = currentApi.fetchConfig(currentSiteId)
|
||||
val cached = CachedConfig(config = config, fetchedAtMs = System.currentTimeMillis())
|
||||
storage?.saveCachedConfig(cached)
|
||||
|
||||
stateMutex.withLock {
|
||||
siteConfig = config
|
||||
// Stamp the banner version onto the current state.
|
||||
consentState = consentState?.copy(bannerVersion = config.bannerVersion)
|
||||
}
|
||||
|
||||
// Apply GCM defaults for new visitors.
|
||||
gcmBridge.applyDefaults(config)
|
||||
config
|
||||
}.getOrNull()
|
||||
// Non-fatal — the SDK can operate with cached or default config.
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal: Apply Consent
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private suspend fun applyConsent(transform: (ConsentState) -> ConsentState) {
|
||||
val newState = stateMutex.withLock {
|
||||
val current = consentState ?: return
|
||||
val transformed = transform(current)
|
||||
// Stamp the current banner version.
|
||||
val stamped = transformed.copy(
|
||||
bannerVersion = siteConfig?.bannerVersion ?: current.bannerVersion,
|
||||
)
|
||||
consentState = stamped
|
||||
stamped
|
||||
}
|
||||
|
||||
// Persist locally.
|
||||
storage?.saveState(newState)
|
||||
|
||||
// Signal GCM.
|
||||
gcmBridge.applyConsent(newState)
|
||||
|
||||
// Generate TC string.
|
||||
val tcString = siteConfig?.let { TCFStringEncoder.encode(newState, it) }
|
||||
|
||||
// Sync to server (best-effort; failures are non-fatal).
|
||||
syncConsent(newState, tcString)
|
||||
|
||||
// Notify listeners and broadcast on the main thread.
|
||||
withContext(Dispatchers.Main) {
|
||||
notifyListeners(newState)
|
||||
broadcastConsentChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncConsent(state: ConsentState, tcString: String?) {
|
||||
val currentSiteId = siteId ?: return
|
||||
val currentApi = api ?: return
|
||||
|
||||
val payload = ConsentPayload.from(
|
||||
siteId = currentSiteId,
|
||||
state = state,
|
||||
tcString = tcString,
|
||||
) ?: return
|
||||
|
||||
runCatching { currentApi.postConsent(payload) }
|
||||
// Consent is stored locally; server sync failure is non-fatal.
|
||||
// In production, implement a retry queue backed by WorkManager.
|
||||
}
|
||||
|
||||
private fun notifyListeners(state: ConsentState) {
|
||||
val snapshot = synchronized(listenerLock) { listeners.toList() }
|
||||
snapshot.forEach { it.onConsentChanged(state) }
|
||||
}
|
||||
|
||||
private fun broadcastConsentChanged() {
|
||||
appContext?.sendBroadcast(Intent(ACTION_CONSENT_CHANGED))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.consentos
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents the complete consent state for a visitor.
|
||||
*
|
||||
* This model mirrors the web consent cookie structure for cross-platform consistency.
|
||||
* It is persisted locally via [ConsentStorage] and synced to the server.
|
||||
*/
|
||||
@Serializable
|
||||
data class ConsentState(
|
||||
/**
|
||||
* A stable, anonymous identifier for this device/visitor.
|
||||
* Generated once and persisted across sessions.
|
||||
*/
|
||||
val visitorId: String,
|
||||
|
||||
/** The set of categories the visitor has explicitly accepted. */
|
||||
val accepted: Set<ConsentCategory> = emptySet(),
|
||||
|
||||
/** The set of categories the visitor has explicitly rejected. */
|
||||
val rejected: Set<ConsentCategory> = emptySet(),
|
||||
|
||||
/**
|
||||
* The timestamp (epoch milliseconds) at which consent was last recorded.
|
||||
* `null` until the user interacts with the banner.
|
||||
*/
|
||||
val consentedAtMs: Long? = null,
|
||||
|
||||
/**
|
||||
* The banner configuration version active when consent was collected.
|
||||
* Used to detect when consent must be re-collected after a config change.
|
||||
*/
|
||||
val bannerVersion: String? = null,
|
||||
) {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Derived State
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether the user has interacted with the banner (accepted or rejected).
|
||||
*
|
||||
* Returns `false` when the state represents the pre-consent default.
|
||||
*/
|
||||
val hasInteracted: Boolean
|
||||
get() = consentedAtMs != null
|
||||
|
||||
/**
|
||||
* Returns `true` if the user has granted consent for the given [category].
|
||||
*
|
||||
* [ConsentCategory.NECESSARY] is always considered granted regardless of the stored state.
|
||||
*/
|
||||
fun isGranted(category: ConsentCategory): Boolean {
|
||||
if (category == ConsentCategory.NECESSARY) return true
|
||||
return category in accepted
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the user has explicitly denied consent for the given [category].
|
||||
*/
|
||||
fun isDenied(category: ConsentCategory): Boolean {
|
||||
if (category == ConsentCategory.NECESSARY) return false
|
||||
return category in rejected
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mutations — returns new instances (immutable pattern)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a new state with all non-necessary categories accepted.
|
||||
*/
|
||||
fun acceptingAll(): ConsentState {
|
||||
val allOptional = ConsentCategory.entries.filter { it.requiresConsent }.toSet()
|
||||
return copy(
|
||||
accepted = allOptional,
|
||||
rejected = emptySet(),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new state with all non-necessary categories rejected.
|
||||
*/
|
||||
fun rejectingAll(): ConsentState {
|
||||
val allOptional = ConsentCategory.entries.filter { it.requiresConsent }.toSet()
|
||||
return copy(
|
||||
accepted = emptySet(),
|
||||
rejected = allOptional,
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new state accepting only the specified [categories] (and rejecting all others).
|
||||
*
|
||||
* Passing [ConsentCategory.NECESSARY] in the set is a no-op — it will not appear in
|
||||
* either [accepted] or [rejected].
|
||||
*/
|
||||
fun accepting(categories: Set<ConsentCategory>): ConsentState {
|
||||
val allOptional = ConsentCategory.entries.filter { it.requiresConsent }.toSet()
|
||||
val toAccept = categories.filter { it.requiresConsent }.toSet()
|
||||
val toReject = allOptional - toAccept
|
||||
return copy(
|
||||
accepted = toAccept,
|
||||
rejected = toReject,
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.consentos
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.UUID
|
||||
|
||||
// =============================================================================
|
||||
// Protocol (Interface)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Abstracts local persistence so the storage layer can be swapped in tests.
|
||||
*/
|
||||
interface ConsentStorageProtocol {
|
||||
fun loadState(): ConsentState?
|
||||
fun saveState(state: ConsentState)
|
||||
fun clearState()
|
||||
|
||||
fun loadCachedConfig(): CachedConfig?
|
||||
fun saveCachedConfig(cached: CachedConfig)
|
||||
fun clearCachedConfig()
|
||||
|
||||
/** Loads or generates a stable visitor ID. */
|
||||
fun visitorId(): String
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EncryptedSharedPreferences Implementation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Persists consent state and site configuration using [EncryptedSharedPreferences].
|
||||
*
|
||||
* All keys are namespaced under `com.cmp.consent` to avoid collisions.
|
||||
* Uses AES-256 encryption via the AndroidKeyStore.
|
||||
*
|
||||
* On API 26+ (Android 8.0), [EncryptedSharedPreferences] is the recommended approach
|
||||
* for storing sensitive data like consent records.
|
||||
*/
|
||||
class ConsentStorage(context: Context) : ConsentStorageProtocol {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Keys
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private object Keys {
|
||||
const val CONSENT_STATE = "com.cmp.consent.state"
|
||||
const val CACHED_CONFIG = "com.cmp.consent.config"
|
||||
const val VISITOR_ID = "com.cmp.consent.visitorId"
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Dependencies
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private val prefs: SharedPreferences = buildEncryptedPrefs(context)
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ConsentState
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
override fun loadState(): ConsentState? {
|
||||
val raw = prefs.getString(Keys.CONSENT_STATE, null) ?: return null
|
||||
return runCatching { json.decodeFromString<ConsentState>(raw) }.getOrNull()
|
||||
}
|
||||
|
||||
override fun saveState(state: ConsentState) {
|
||||
val raw = runCatching { json.encodeToString(state) }.getOrNull() ?: return
|
||||
prefs.edit().putString(Keys.CONSENT_STATE, raw).apply()
|
||||
}
|
||||
|
||||
override fun clearState() {
|
||||
prefs.edit().remove(Keys.CONSENT_STATE).apply()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cached Config
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
override fun loadCachedConfig(): CachedConfig? {
|
||||
val raw = prefs.getString(Keys.CACHED_CONFIG, null) ?: return null
|
||||
return runCatching { json.decodeFromString<CachedConfig>(raw) }.getOrNull()
|
||||
}
|
||||
|
||||
override fun saveCachedConfig(cached: CachedConfig) {
|
||||
val raw = runCatching { json.encodeToString(cached) }.getOrNull() ?: return
|
||||
prefs.edit().putString(Keys.CACHED_CONFIG, raw).apply()
|
||||
}
|
||||
|
||||
override fun clearCachedConfig() {
|
||||
prefs.edit().remove(Keys.CACHED_CONFIG).apply()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Visitor ID
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the persisted visitor ID, generating and saving a new UUID if absent.
|
||||
*
|
||||
* The visitor ID is stored in a separate, plaintext SharedPreferences file so it
|
||||
* persists even if the encrypted preferences are reset.
|
||||
*/
|
||||
override fun visitorId(): String {
|
||||
val existing = prefs.getString(Keys.VISITOR_ID, null)
|
||||
if (existing != null) return existing
|
||||
val newId = UUID.randomUUID().toString()
|
||||
prefs.edit().putString(Keys.VISITOR_ID, newId).apply()
|
||||
return newId
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun buildEncryptedPrefs(context: Context): SharedPreferences {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"com_cmp_consent_prefs",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.consentos
|
||||
|
||||
/**
|
||||
* Google Consent Mode v2 consent type strings.
|
||||
*/
|
||||
enum class GCMConsentType(val value: String) {
|
||||
ANALYTICS_STORAGE("analytics_storage"),
|
||||
AD_STORAGE("ad_storage"),
|
||||
AD_USER_DATA("ad_user_data"),
|
||||
AD_PERSONALISATION("ad_personalization"),
|
||||
FUNCTIONALITY_STORAGE("functionality_storage"),
|
||||
PERSONALISATION_STORAGE("personalization_storage"),
|
||||
SECURITY_STORAGE("security_storage"),
|
||||
}
|
||||
|
||||
/**
|
||||
* The granted/denied status for a GCM consent type.
|
||||
*/
|
||||
enum class GCMConsentStatus(val value: String) {
|
||||
GRANTED("granted"),
|
||||
DENIED("denied"),
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GCM Analytics Provider Protocol
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Abstracts the Firebase Analytics / Google Tag Manager call surface.
|
||||
*
|
||||
* Implement this interface and pass an instance to [GCMBridge] to forward consent
|
||||
* signals to Firebase Analytics. A no-op implementation ([NoOpGCMAnalyticsProvider])
|
||||
* is used when Firebase is not present.
|
||||
*
|
||||
* Example with Firebase Analytics:
|
||||
* ```kotlin
|
||||
* class FirebaseGCMProvider : GCMAnalyticsProvider {
|
||||
* override fun setConsentDefaults(defaults: Map<String, String>) {
|
||||
* val consentMap = defaults.mapKeys { FirebaseAnalytics.ConsentType.valueOf(it.key.uppercase()) }
|
||||
* .mapValues { FirebaseAnalytics.ConsentStatus.valueOf(it.value.uppercase()) }
|
||||
* Firebase.analytics.setConsent(consentMap)
|
||||
* }
|
||||
*
|
||||
* override fun updateConsent(updates: Map<String, String>) {
|
||||
* val consentMap = updates.mapKeys { FirebaseAnalytics.ConsentType.valueOf(it.key.uppercase()) }
|
||||
* .mapValues { FirebaseAnalytics.ConsentStatus.valueOf(it.value.uppercase()) }
|
||||
* Firebase.analytics.setConsent(consentMap)
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
interface GCMAnalyticsProvider {
|
||||
/** Sets the default consent state before any user interaction. */
|
||||
fun setConsentDefaults(defaults: Map<String, String>)
|
||||
|
||||
/** Updates consent state after the user interacts with the banner. */
|
||||
fun updateConsent(updates: Map<String, String>)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// No-Op Provider
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* A no-op [GCMAnalyticsProvider] used when Firebase Analytics is not linked.
|
||||
*
|
||||
* This is the default provider; replace it with a Firebase implementation to
|
||||
* activate Google Consent Mode v2 signals.
|
||||
*/
|
||||
class NoOpGCMAnalyticsProvider : GCMAnalyticsProvider {
|
||||
override fun setConsentDefaults(defaults: Map<String, String>) = Unit
|
||||
override fun updateConsent(updates: Map<String, String>) = Unit
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GCM Bridge
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Maps CMP consent state to Google Consent Mode v2 signals.
|
||||
*
|
||||
* On app launch, call [applyDefaults] with the site config before the user interacts.
|
||||
* After consent is collected, call [applyConsent] to update GCM.
|
||||
*
|
||||
* @param provider The analytics provider to forward signals to. Defaults to a no-op.
|
||||
*/
|
||||
class GCMBridge(
|
||||
private val provider: GCMAnalyticsProvider = NoOpGCMAnalyticsProvider(),
|
||||
) {
|
||||
|
||||
/**
|
||||
* Sends default (pre-consent) consent signals to GCM based on the site's blocking mode.
|
||||
*
|
||||
* Call this as early as possible — ideally before any analytics events are sent.
|
||||
*
|
||||
* @param config The effective site configuration.
|
||||
*/
|
||||
fun applyDefaults(config: ConsentConfig) {
|
||||
val defaults = buildDefaults(config.blockingMode)
|
||||
provider.setConsentDefaults(defaults)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates GCM consent signals to reflect the user's explicit choices.
|
||||
*
|
||||
* @param state The resolved consent state after user interaction.
|
||||
*/
|
||||
fun applyConsent(state: ConsentState) {
|
||||
val updates = buildConsentMap(state)
|
||||
provider.updateConsent(updates)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Builds the default GCM consent map based on blocking mode.
|
||||
*
|
||||
* - `opt_in`: all types denied by default (GDPR).
|
||||
* - `opt_out`: all types granted by default (CCPA).
|
||||
* - `informational`: all types granted.
|
||||
*/
|
||||
private fun buildDefaults(mode: ConsentConfig.BlockingMode): Map<String, String> {
|
||||
val status = if (mode == ConsentConfig.BlockingMode.OPT_IN) {
|
||||
GCMConsentStatus.DENIED
|
||||
} else {
|
||||
GCMConsentStatus.GRANTED
|
||||
}
|
||||
return GCMConsentType.entries.associate { it.value to status.value }
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the consent state's accepted/rejected categories to GCM consent type values.
|
||||
*/
|
||||
private fun buildConsentMap(state: ConsentState): Map<String, String> {
|
||||
val map = mutableMapOf<String, String>()
|
||||
|
||||
// security_storage is always granted — it is required for security functionality.
|
||||
map[GCMConsentType.SECURITY_STORAGE.value] = GCMConsentStatus.GRANTED.value
|
||||
|
||||
for (category in ConsentCategory.entries) {
|
||||
val gcmType = category.gcmConsentType ?: continue
|
||||
val status = if (state.isGranted(category)) {
|
||||
GCMConsentStatus.GRANTED
|
||||
} else {
|
||||
GCMConsentStatus.DENIED
|
||||
}
|
||||
map[gcmType] = status.value
|
||||
}
|
||||
|
||||
// ad_user_data and ad_personalization follow the marketing category.
|
||||
val marketingGranted = state.isGranted(ConsentCategory.MARKETING)
|
||||
val marketingStatus = if (marketingGranted) GCMConsentStatus.GRANTED else GCMConsentStatus.DENIED
|
||||
map[GCMConsentType.AD_USER_DATA.value] = marketingStatus.value
|
||||
map[GCMConsentType.AD_PERSONALISATION.value] = marketingStatus.value
|
||||
|
||||
return map
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.consentos
|
||||
|
||||
import android.util.Base64
|
||||
|
||||
/**
|
||||
* Encodes a TC string (Transparency & Consent Framework v2.2) from consent state.
|
||||
*
|
||||
* The TC string is a Base64url-encoded bit field described in the IAB TCF v2.2 specification.
|
||||
* This implementation encodes the core consent section (segment type 0), which is sufficient
|
||||
* for signalling purpose consent to downstream vendors.
|
||||
*
|
||||
* Reference:
|
||||
* https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework
|
||||
*/
|
||||
object TCFStringEncoder {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Constants
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** TCF specification version. */
|
||||
private const val SPEC_VERSION = 2
|
||||
|
||||
/** A fixed CMP ID — replace with your registered IAB CMP ID in production. */
|
||||
private const val CMP_ID = 0
|
||||
|
||||
/** CMP SDK version number. */
|
||||
private const val CMP_VERSION = 1
|
||||
|
||||
/** IAB consent language (EN). */
|
||||
private const val CONSENT_LANGUAGE = "EN"
|
||||
|
||||
/** Vendor list version. In production, fetch from the GVL. */
|
||||
private const val VENDOR_LIST_VERSION = 1
|
||||
|
||||
/** Number of TCF purposes defined in the specification. */
|
||||
private const val TCF_PURPOSE_COUNT = 24
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Encodes a TC string for the given consent state and site configuration.
|
||||
*
|
||||
* @param state The resolved consent state containing accepted/rejected categories.
|
||||
* @param config The site configuration (used for CMP metadata).
|
||||
* @return A Base64url-encoded TC string, or `null` if the state has no interaction.
|
||||
*/
|
||||
fun encode(state: ConsentState, config: ConsentConfig): String? {
|
||||
if (!state.hasInteracted) return null
|
||||
val consentedAtMs = state.consentedAtMs ?: return null
|
||||
|
||||
// Derive the set of consented TCF purpose IDs from accepted categories.
|
||||
val consentedPurposeIds: Set<Int> = state.accepted
|
||||
.flatMap { it.tcfPurposeIds }
|
||||
.toSet()
|
||||
|
||||
return buildCoreString(consentedAtMs, consentedPurposeIds)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Core String Construction
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun buildCoreString(
|
||||
consentedAtMs: Long,
|
||||
consentedPurposeIds: Set<Int>,
|
||||
): String {
|
||||
val bits = BitWriter()
|
||||
|
||||
// --- Core segment fields (IAB TCF v2.2 spec, Table 1) ---
|
||||
|
||||
// Version (6 bits)
|
||||
bits.write(SPEC_VERSION, 6)
|
||||
|
||||
// Created — deciseconds since epoch (36 bits)
|
||||
val deciseconds = (consentedAtMs / 100).toInt()
|
||||
bits.write(deciseconds, 36)
|
||||
|
||||
// LastUpdated — deciseconds since epoch (36 bits)
|
||||
bits.write(deciseconds, 36)
|
||||
|
||||
// CmpId (12 bits)
|
||||
bits.write(CMP_ID, 12)
|
||||
|
||||
// CmpVersion (12 bits)
|
||||
bits.write(CMP_VERSION, 12)
|
||||
|
||||
// ConsentScreen (6 bits) — screen number within the CMP UI
|
||||
bits.write(1, 6)
|
||||
|
||||
// ConsentLanguage (12 bits) — two 6-bit characters, A=0 … Z=25
|
||||
val (langFirst, langSecond) = encodeTwoLetterCode(CONSENT_LANGUAGE)
|
||||
bits.write(langFirst, 6)
|
||||
bits.write(langSecond, 6)
|
||||
|
||||
// VendorListVersion (12 bits)
|
||||
bits.write(VENDOR_LIST_VERSION, 12)
|
||||
|
||||
// TcfPolicyVersion (6 bits) — must be 4 for TCF v2.2
|
||||
bits.write(4, 6)
|
||||
|
||||
// IsServiceSpecific (1 bit)
|
||||
bits.write(0, 1)
|
||||
|
||||
// UseNonStandardTexts (1 bit)
|
||||
bits.write(0, 1)
|
||||
|
||||
// SpecialFeatureOptIns (12 bits) — none opted in
|
||||
bits.write(0, 12)
|
||||
|
||||
// PurposesConsent (24 bits) — one bit per purpose, purpose 1 first
|
||||
for (purposeId in 1..TCF_PURPOSE_COUNT) {
|
||||
bits.write(if (purposeId in consentedPurposeIds) 1 else 0, 1)
|
||||
}
|
||||
|
||||
// PurposesLITransparency (24 bits) — legitimate interest; none asserted
|
||||
bits.write(0, 24)
|
||||
|
||||
// PurposeOneTreatment (1 bit)
|
||||
bits.write(0, 1)
|
||||
|
||||
// PublisherCC (12 bits) — "GB"
|
||||
val (ccFirst, ccSecond) = encodeTwoLetterCode("GB")
|
||||
bits.write(ccFirst, 6)
|
||||
bits.write(ccSecond, 6)
|
||||
|
||||
// Vendor Consents — BitRange encoding with MaxVendorId = 0 (no vendors)
|
||||
bits.write(0, 16) // MaxVendorId
|
||||
bits.write(0, 1) // IsRangeEncoding = false
|
||||
|
||||
// Vendor Legitimate Interests — MaxVendorId = 0
|
||||
bits.write(0, 16)
|
||||
bits.write(0, 1)
|
||||
|
||||
// Publisher Restrictions count = 0
|
||||
bits.write(0, 12)
|
||||
|
||||
return base64UrlEncode(bits.toByteArray())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Encodes a two-letter language/country code into two 6-bit integers (A=0, Z=25).
|
||||
*/
|
||||
private fun encodeTwoLetterCode(code: String): Pair<Int, Int> {
|
||||
val upper = code.uppercase()
|
||||
return if (upper.length >= 2) {
|
||||
val first = upper[0].code - 'A'.code
|
||||
val second = upper[1].code - 'A'.code
|
||||
Pair(first.coerceIn(0, 25), second.coerceIn(0, 25))
|
||||
} else {
|
||||
Pair(4, 13) // "EN" fallback (E=4, N=13)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a [ByteArray] to a Base64url string (RFC 4648, no padding).
|
||||
*/
|
||||
private fun base64UrlEncode(data: ByteArray): String =
|
||||
Base64.encodeToString(data, Base64.NO_PADDING or Base64.NO_WRAP or Base64.URL_SAFE)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Bit Writer
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* A utility class for packing integers into a bit-level byte buffer.
|
||||
*
|
||||
* Bits are written from the most-significant bit of each byte first (big-endian).
|
||||
*/
|
||||
private class BitWriter {
|
||||
|
||||
private val bytes = mutableListOf<Byte>()
|
||||
private var currentByte: Int = 0
|
||||
private var bitPosition: Int = 0 // 0 = MSB of current byte
|
||||
|
||||
/**
|
||||
* Writes [bitCount] bits from [value], starting from the most-significant bit.
|
||||
*/
|
||||
fun write(value: Int, bitCount: Int) {
|
||||
for (i in bitCount - 1 downTo 0) {
|
||||
val bit = (value shr i) and 1
|
||||
currentByte = currentByte or (bit shl (7 - bitPosition))
|
||||
bitPosition++
|
||||
if (bitPosition == 8) {
|
||||
bytes.add(currentByte.toByte())
|
||||
currentByte = 0
|
||||
bitPosition = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes any remaining partial byte and returns the accumulated bytes.
|
||||
*/
|
||||
fun toByteArray(): ByteArray {
|
||||
val result = bytes.toMutableList()
|
||||
if (bitPosition > 0) result.add(currentByte.toByte())
|
||||
return result.toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.consentos.ui
|
||||
|
||||
import android.graphics.Color
|
||||
import androidx.annotation.ColorInt
|
||||
import com.consentos.ConsentConfig
|
||||
|
||||
/**
|
||||
* Resolved colour and dimension theme for the consent banner.
|
||||
*
|
||||
* Derived from [ConsentConfig.BannerConfig] values, falling back to sensible defaults
|
||||
* when the API config is absent.
|
||||
*/
|
||||
data class BannerTheme(
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Colours (as ARGB packed ints)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@ColorInt val backgroundColor: Int,
|
||||
@ColorInt val textColor: Int,
|
||||
@ColorInt val accentColor: Int,
|
||||
@ColorInt val secondaryTextColor: Int,
|
||||
@ColorInt val dividerColor: Int,
|
||||
@ColorInt val acceptButtonBackground: Int,
|
||||
@ColorInt val acceptButtonTextColor: Int,
|
||||
@ColorInt val rejectButtonBackground: Int,
|
||||
@ColorInt val rejectButtonTextColor: Int,
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Dimensions (dp)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
val cornerRadiusDp: Float,
|
||||
val horizontalPaddingDp: Float,
|
||||
val verticalPaddingDp: Float,
|
||||
val buttonHeightDp: Float,
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Button / Copy Labels
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
val acceptButtonText: String,
|
||||
val rejectButtonText: String,
|
||||
val manageButtonText: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
// Default colour constants (matches iOS SDK defaults for consistency).
|
||||
private const val DEFAULT_ACCENT_HEX = "#1A73E8"
|
||||
private const val DEFAULT_BACKGROUND_HEX = "#FFFFFF"
|
||||
private const val DEFAULT_TEXT_HEX = "#1A1A1A"
|
||||
private const val DEFAULT_SECONDARY_ALPHA = 0x99 // ~60% opacity
|
||||
|
||||
/**
|
||||
* Creates a [BannerTheme] from the banner configuration returned by the API.
|
||||
*
|
||||
* Hex values not present in the config fall back to neutral defaults.
|
||||
*
|
||||
* @param config The [ConsentConfig.BannerConfig] from the API response.
|
||||
*/
|
||||
fun from(config: ConsentConfig.BannerConfig): BannerTheme {
|
||||
val background = parseHex(config.backgroundColor, DEFAULT_BACKGROUND_HEX)
|
||||
val text = parseHex(config.textColor, DEFAULT_TEXT_HEX)
|
||||
val accent = parseHex(config.accentColor, DEFAULT_ACCENT_HEX)
|
||||
|
||||
// Secondary text: same hue as primary but with reduced opacity.
|
||||
val secondaryText = Color.argb(
|
||||
DEFAULT_SECONDARY_ALPHA,
|
||||
Color.red(text),
|
||||
Color.green(text),
|
||||
Color.blue(text),
|
||||
)
|
||||
|
||||
// Divider: very low opacity version of the text colour.
|
||||
val divider = Color.argb(
|
||||
0x1F, // ~12% opacity
|
||||
Color.red(text),
|
||||
Color.green(text),
|
||||
Color.blue(text),
|
||||
)
|
||||
|
||||
// Reject button: light grey background.
|
||||
val rejectBackground = Color.argb(0xFF, 0xF2, 0xF2, 0xF2)
|
||||
|
||||
return BannerTheme(
|
||||
backgroundColor = background,
|
||||
textColor = text,
|
||||
accentColor = accent,
|
||||
secondaryTextColor = secondaryText,
|
||||
dividerColor = divider,
|
||||
acceptButtonBackground = accent,
|
||||
acceptButtonTextColor = Color.WHITE,
|
||||
rejectButtonBackground = rejectBackground,
|
||||
rejectButtonTextColor = text,
|
||||
cornerRadiusDp = 16f,
|
||||
horizontalPaddingDp = 16f,
|
||||
verticalPaddingDp = 16f,
|
||||
buttonHeightDp = 48f,
|
||||
acceptButtonText = config.acceptButtonText ?: "Accept All",
|
||||
rejectButtonText = config.rejectButtonText ?: "Reject All",
|
||||
manageButtonText = config.manageButtonText ?: "Manage Preferences",
|
||||
title = config.title ?: "We value your privacy",
|
||||
description = config.description
|
||||
?: "We use cookies to improve your experience and for analytics.",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Default theme used when no configuration has been loaded yet.
|
||||
*/
|
||||
val default: BannerTheme by lazy {
|
||||
from(ConsentConfig.BannerConfig())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parses a hex colour string such as `"#1A73E8"` or `"1A73E8"`.
|
||||
*
|
||||
* Returns the [fallback] colour if the string is null or malformed.
|
||||
*/
|
||||
@ColorInt
|
||||
private fun parseHex(hex: String?, fallbackHex: String): Int {
|
||||
val input = hex?.trimStart('#') ?: return Color.parseColor(fallbackHex)
|
||||
return runCatching { Color.parseColor("#$input") }
|
||||
.getOrDefault(Color.parseColor(fallbackHex))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.consentos.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.consentos.ConsentOS
|
||||
|
||||
/**
|
||||
* A transparent, trampoline [AppCompatActivity] that hosts the [ConsentBottomSheet].
|
||||
*
|
||||
* This activity is used when the host app cannot directly access a [FragmentManager]
|
||||
* (e.g. from a Service or Application context). Launch it with [launch]:
|
||||
*
|
||||
* ```kotlin
|
||||
* ConsentBannerActivity.launch(context)
|
||||
* ```
|
||||
*
|
||||
* The activity is declared as transparent (`Theme.AppCompat.Translucent`) so only
|
||||
* the bottom sheet is visible. It finishes automatically when the sheet is dismissed.
|
||||
*/
|
||||
class ConsentBannerActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Launches the [ConsentBannerActivity] from any [Context].
|
||||
*
|
||||
* @param context The context from which to start the activity.
|
||||
*/
|
||||
fun launch(context: Context) {
|
||||
val intent = Intent(context, ConsentBannerActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
val config = ConsentOS.instance.siteConfig
|
||||
val sheet = ConsentBottomSheet.newInstance(config)
|
||||
|
||||
sheet.show(supportFragmentManager, "cmp_consent_banner")
|
||||
|
||||
// Finish the activity when the sheet is dismissed.
|
||||
supportFragmentManager.setFragmentResultListener(
|
||||
"cmp_consent_result",
|
||||
this,
|
||||
) { _, _ -> finish() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
// Prevent dismissal via back press without explicit user choice.
|
||||
// The user must interact with the banner buttons.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package com.consentos.ui
|
||||
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.CompoundButton
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Switch
|
||||
import android.widget.TextView
|
||||
import com.consentos.ConsentOS
|
||||
import com.consentos.ConsentCategory
|
||||
import com.consentos.ConsentConfig
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Displays the consent banner as a Material [BottomSheetDialogFragment].
|
||||
*
|
||||
* The sheet is themed from the [ConsentConfig.BannerConfig] passed at construction
|
||||
* (or from defaults if no config is available). It provides three primary actions:
|
||||
*
|
||||
* - **Accept All** — grants all non-necessary categories.
|
||||
* - **Reject All** — denies all non-necessary categories.
|
||||
* - **Manage Preferences** — reveals per-category toggles.
|
||||
*
|
||||
* Use [newInstance] to create an instance and [show] to display it:
|
||||
*
|
||||
* ```kotlin
|
||||
* val sheet = ConsentBottomSheet.newInstance(config)
|
||||
* sheet.show(supportFragmentManager, "cmp_consent_banner")
|
||||
* ```
|
||||
*/
|
||||
class ConsentBottomSheet : BottomSheetDialogFragment() {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Factory
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
companion object {
|
||||
private const val ARG_CONFIG_JSON = "config_json"
|
||||
|
||||
/**
|
||||
* Creates a [ConsentBottomSheet] with the given site configuration.
|
||||
*
|
||||
* @param config The effective site configuration, or `null` to use defaults.
|
||||
*/
|
||||
fun newInstance(config: ConsentConfig?): ConsentBottomSheet {
|
||||
val sheet = ConsentBottomSheet()
|
||||
if (config != null) {
|
||||
val bundle = Bundle()
|
||||
bundle.putString(ARG_CONFIG_JSON, serializeConfig(config))
|
||||
sheet.arguments = bundle
|
||||
}
|
||||
return sheet
|
||||
}
|
||||
|
||||
private fun serializeConfig(config: ConsentConfig): String =
|
||||
// Simple serialisation — uses the default Json instance for round-tripping.
|
||||
runCatching {
|
||||
Json.encodeToString(ConsentConfig.serializer(), config)
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// State
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private var theme: BannerTheme = BannerTheme.default
|
||||
private var config: ConsentConfig? = null
|
||||
|
||||
/** Coroutine scope tied to the fragment's lifecycle. */
|
||||
private val sheetScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val configJson = arguments?.getString(ARG_CONFIG_JSON)
|
||||
if (!configJson.isNullOrEmpty()) {
|
||||
config = runCatching {
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
}.decodeFromString(ConsentConfig.serializer(), configJson)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
// Fall back to the config cached in the singleton if none was passed.
|
||||
if (config == null) {
|
||||
config = ConsentOS.instance.siteConfig
|
||||
}
|
||||
|
||||
theme = config?.bannerConfig?.let { BannerTheme.from(it) } ?: BannerTheme.default
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View = buildBannerView()
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
sheetScope.cancel()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// View Construction (programmatic — no XML layout dependency)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Builds the banner view programmatically to keep the SDK self-contained
|
||||
* and avoid requiring host apps to merge resource files.
|
||||
*/
|
||||
private fun buildBannerView(): View {
|
||||
val ctx = requireContext()
|
||||
|
||||
// Root container
|
||||
val root = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setBackgroundColor(theme.backgroundColor)
|
||||
val dp16 = dpToPx(theme.horizontalPaddingDp).toInt()
|
||||
setPadding(dp16, dp16, dp16, dp16)
|
||||
background = GradientDrawable().apply {
|
||||
setColor(theme.backgroundColor)
|
||||
cornerRadii = floatArrayOf(
|
||||
dpToPx(theme.cornerRadiusDp), dpToPx(theme.cornerRadiusDp),
|
||||
dpToPx(theme.cornerRadiusDp), dpToPx(theme.cornerRadiusDp),
|
||||
0f, 0f,
|
||||
0f, 0f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Title
|
||||
val titleView = TextView(ctx).apply {
|
||||
text = theme.title
|
||||
textSize = 18f
|
||||
setTextColor(theme.textColor)
|
||||
val lp = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
lp.bottomMargin = dpToPx(8f).toInt()
|
||||
layoutParams = lp
|
||||
}
|
||||
root.addView(titleView)
|
||||
|
||||
// Description
|
||||
val descriptionView = TextView(ctx).apply {
|
||||
text = theme.description
|
||||
textSize = 14f
|
||||
setTextColor(theme.secondaryTextColor)
|
||||
val lp = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
lp.bottomMargin = dpToPx(16f).toInt()
|
||||
layoutParams = lp
|
||||
}
|
||||
root.addView(descriptionView)
|
||||
|
||||
// Accept All button
|
||||
val acceptButton = buildPrimaryButton(theme.acceptButtonText).apply {
|
||||
setBackgroundColor(theme.acceptButtonBackground)
|
||||
setTextColor(theme.acceptButtonTextColor)
|
||||
setOnClickListener {
|
||||
sheetScope.launch {
|
||||
ConsentOS.instance.acceptAll()
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
}
|
||||
root.addView(acceptButton)
|
||||
|
||||
val spacer1 = View(ctx).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, dpToPx(8f).toInt(),
|
||||
)
|
||||
}
|
||||
root.addView(spacer1)
|
||||
|
||||
// Reject All button
|
||||
val rejectButton = buildSecondaryButton(theme.rejectButtonText).apply {
|
||||
setBackgroundColor(theme.rejectButtonBackground)
|
||||
setTextColor(theme.rejectButtonTextColor)
|
||||
setOnClickListener {
|
||||
sheetScope.launch {
|
||||
ConsentOS.instance.rejectAll()
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
}
|
||||
root.addView(rejectButton)
|
||||
|
||||
val spacer2 = View(ctx).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, dpToPx(8f).toInt(),
|
||||
)
|
||||
}
|
||||
root.addView(spacer2)
|
||||
|
||||
// Manage Preferences button
|
||||
val manageButton = buildSecondaryButton(theme.manageButtonText).apply {
|
||||
setBackgroundColor(theme.rejectButtonBackground)
|
||||
setTextColor(theme.accentColor) // Use accent colour for the manage button text
|
||||
setOnClickListener { showPreferencesPanel(root) }
|
||||
}
|
||||
root.addView(manageButton)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a per-category preferences panel below the action buttons.
|
||||
*
|
||||
* This is a simplified in-line expansion; a production implementation would open
|
||||
* a separate screen or expand via a RecyclerView with toggle switches.
|
||||
*/
|
||||
private fun showPreferencesPanel(root: LinearLayout) {
|
||||
val ctx = root.context
|
||||
val categories = config?.enabledCategories
|
||||
?: ConsentCategory.entries.filter { it.requiresConsent }
|
||||
|
||||
val panelTitle = TextView(ctx).apply {
|
||||
text = "Manage Preferences"
|
||||
textSize = 16f
|
||||
setTextColor(theme.textColor)
|
||||
val lp = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
lp.topMargin = dpToPx(16f).toInt()
|
||||
lp.bottomMargin = dpToPx(8f).toInt()
|
||||
layoutParams = lp
|
||||
}
|
||||
root.addView(panelTitle)
|
||||
|
||||
// Track which categories the user has toggled (start from current state).
|
||||
val selectedCategories = ConsentOS.instance.consentState?.accepted?.toMutableSet()
|
||||
?: mutableSetOf()
|
||||
|
||||
categories.filter { it.requiresConsent }.forEach { category ->
|
||||
val row = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
val lp = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
lp.bottomMargin = dpToPx(8f).toInt()
|
||||
layoutParams = lp
|
||||
}
|
||||
|
||||
val label = TextView(ctx).apply {
|
||||
text = category.displayName
|
||||
textSize = 14f
|
||||
setTextColor(theme.textColor)
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
row.addView(label)
|
||||
|
||||
@Suppress("DEPRECATION") // Switch is deprecated in API 29+ but works on API 26+
|
||||
val toggle = Switch(ctx).apply {
|
||||
isChecked = category in selectedCategories
|
||||
setOnCheckedChangeListener { _: CompoundButton, checked: Boolean ->
|
||||
if (checked) selectedCategories.add(category)
|
||||
else selectedCategories.remove(category)
|
||||
}
|
||||
}
|
||||
row.addView(toggle)
|
||||
|
||||
root.addView(row)
|
||||
}
|
||||
|
||||
// Save preferences button
|
||||
val saveButton = buildPrimaryButton("Save Preferences").apply {
|
||||
setBackgroundColor(theme.acceptButtonBackground)
|
||||
setTextColor(theme.acceptButtonTextColor)
|
||||
val lp = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, dpToPx(theme.buttonHeightDp).toInt(),
|
||||
)
|
||||
lp.topMargin = dpToPx(8f).toInt()
|
||||
layoutParams = lp
|
||||
setOnClickListener {
|
||||
sheetScope.launch {
|
||||
ConsentOS.instance.acceptCategories(selectedCategories.toList())
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
}
|
||||
root.addView(saveButton)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun buildPrimaryButton(label: String) = Button(requireContext()).apply {
|
||||
text = label
|
||||
isAllCaps = false
|
||||
textSize = 14f
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
dpToPx(theme.buttonHeightDp).toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildSecondaryButton(label: String) = Button(requireContext()).apply {
|
||||
text = label
|
||||
isAllCaps = false
|
||||
textSize = 14f
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
dpToPx(theme.buttonHeightDp).toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Converts dp to pixels using the current display density. */
|
||||
private fun dpToPx(dp: Float): Float =
|
||||
dp * (requireContext().resources.displayMetrics.density)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package com.consentos
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for the [ConsentAPIProtocol] contract using a mock implementation.
|
||||
*
|
||||
* These tests verify the API contract and [ConsentPayload] serialisation logic
|
||||
* without making real network calls.
|
||||
*/
|
||||
class ConsentAPITest {
|
||||
|
||||
private lateinit var mockAPI: MockConsentAPI
|
||||
private val siteId = "test-site-001"
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockAPI = MockConsentAPI()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// fetchConfig
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `fetchConfig returns config when successful`() = runTest {
|
||||
mockAPI.configToReturn = makeSampleConfig()
|
||||
|
||||
val result = mockAPI.fetchConfig(siteId)
|
||||
|
||||
assertEquals(siteId, result.siteId)
|
||||
assertEquals(1, mockAPI.fetchConfigCallCount)
|
||||
assertEquals(siteId, mockAPI.lastFetchedSiteId)
|
||||
}
|
||||
|
||||
@Test(expected = ConsentAPIException.NetworkFailure::class)
|
||||
fun `fetchConfig throws NetworkFailure on network error`() = runTest {
|
||||
mockAPI.errorToThrow = ConsentAPIException.NetworkFailure(RuntimeException("No connectivity"))
|
||||
mockAPI.fetchConfig(siteId)
|
||||
}
|
||||
|
||||
@Test(expected = ConsentAPIException.UnexpectedStatusCode::class)
|
||||
fun `fetchConfig throws UnexpectedStatusCode on 404`() = runTest {
|
||||
mockAPI.errorToThrow = ConsentAPIException.UnexpectedStatusCode(404)
|
||||
mockAPI.fetchConfig(siteId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetchConfig throws exception with correct status code`() = runTest {
|
||||
mockAPI.errorToThrow = ConsentAPIException.UnexpectedStatusCode(503)
|
||||
try {
|
||||
mockAPI.fetchConfig(siteId)
|
||||
} catch (e: ConsentAPIException.UnexpectedStatusCode) {
|
||||
assertEquals(503, e.code)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// postConsent
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `postConsent sends correct payload`() = runTest {
|
||||
val state = ConsentState(
|
||||
visitorId = "visitor-xyz",
|
||||
accepted = setOf(ConsentCategory.ANALYTICS, ConsentCategory.FUNCTIONAL),
|
||||
rejected = setOf(ConsentCategory.MARKETING),
|
||||
consentedAtMs = 1_700_000_000_000L,
|
||||
bannerVersion = "v2",
|
||||
)
|
||||
val payload = ConsentPayload.from(
|
||||
siteId = siteId,
|
||||
state = state,
|
||||
tcString = "test-tc-string",
|
||||
)!!
|
||||
|
||||
mockAPI.postConsent(payload)
|
||||
|
||||
assertEquals(1, mockAPI.postConsentCallCount)
|
||||
val sent = mockAPI.lastPostedPayload!!
|
||||
assertEquals(siteId, sent.siteId)
|
||||
assertEquals("visitor-xyz", sent.visitorId)
|
||||
assertEquals("android", sent.platform)
|
||||
assertTrue(sent.accepted.contains("analytics"))
|
||||
assertTrue(sent.accepted.contains("functional"))
|
||||
assertTrue(sent.rejected.contains("marketing"))
|
||||
assertEquals("v2", sent.bannerVersion)
|
||||
assertEquals("test-tc-string", sent.tcString)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `postConsent platform is always android`() = runTest {
|
||||
val state = ConsentState(visitorId = "v", consentedAtMs = System.currentTimeMillis())
|
||||
val payload = ConsentPayload.from(siteId = siteId, state = state)!!
|
||||
|
||||
mockAPI.postConsent(payload)
|
||||
|
||||
assertEquals("android", mockAPI.lastPostedPayload?.platform)
|
||||
}
|
||||
|
||||
@Test(expected = ConsentAPIException.UnexpectedStatusCode::class)
|
||||
fun `postConsent throws on server error`() = runTest {
|
||||
mockAPI.postConsentError = ConsentAPIException.UnexpectedStatusCode(500)
|
||||
val payload = ConsentPayload.from(
|
||||
siteId = siteId,
|
||||
state = ConsentState(visitorId = "v", consentedAtMs = System.currentTimeMillis()),
|
||||
)!!
|
||||
mockAPI.postConsent(payload)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ConsentPayload construction
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `ConsentPayload from encodes categories to raw values`() {
|
||||
val state = ConsentState(
|
||||
visitorId = "v1",
|
||||
accepted = setOf(ConsentCategory.ANALYTICS, ConsentCategory.MARKETING),
|
||||
rejected = setOf(ConsentCategory.FUNCTIONAL),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
val payload = ConsentPayload.from(siteId = "s1", state = state)!!
|
||||
|
||||
assertTrue(payload.accepted.contains("analytics"))
|
||||
assertTrue(payload.accepted.contains("marketing"))
|
||||
assertTrue(payload.rejected.contains("functional"))
|
||||
assertFalse(payload.accepted.contains("necessary"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ConsentPayload from returns null when no consentedAtMs`() {
|
||||
val state = ConsentState(visitorId = "v1") // no interaction
|
||||
val payload = ConsentPayload.from(siteId = "s1", state = state)
|
||||
assertTrue(payload == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ConsentPayload consentedAt is formatted as ISO-8601 UTC`() {
|
||||
val ts = 1_700_000_000_000L
|
||||
val state = ConsentState(visitorId = "v1", consentedAtMs = ts)
|
||||
val payload = ConsentPayload.from(siteId = "s1", state = state)!!
|
||||
|
||||
// Should be in the format "2023-11-14T22:13:20Z" (approximately)
|
||||
assertTrue(
|
||||
"consentedAt should match ISO-8601 UTC pattern",
|
||||
payload.consentedAt.matches(Regex("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z")),
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Error messages
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `UnexpectedStatusCode includes code in message`() {
|
||||
val error = ConsentAPIException.UnexpectedStatusCode(503)
|
||||
assertTrue(error.message?.contains("503") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `InvalidURL has non-empty message`() {
|
||||
val error = ConsentAPIException.InvalidURL("not-a-url")
|
||||
assertNotNull(error.message)
|
||||
assertFalse(error.message!!.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NetworkFailure wraps original cause`() {
|
||||
val cause = RuntimeException("timeout")
|
||||
val error = ConsentAPIException.NetworkFailure(cause)
|
||||
assertEquals(cause, error.cause)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun makeSampleConfig() = ConsentConfig(
|
||||
siteId = siteId,
|
||||
siteName = "Test Site",
|
||||
blockingMode = ConsentConfig.BlockingMode.OPT_IN,
|
||||
consentExpiryDays = 365,
|
||||
bannerVersion = "v1",
|
||||
bannerConfig = ConsentConfig.BannerConfig(),
|
||||
categories = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Mock API Implementation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* In-memory [ConsentAPIProtocol] implementation for testing without network calls.
|
||||
*/
|
||||
class MockConsentAPI : ConsentAPIProtocol {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Configurable behaviour
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
var configToReturn: ConsentConfig? = null
|
||||
var errorToThrow: Throwable? = null
|
||||
var postConsentError: Throwable? = null
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Call tracking
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
var fetchConfigCallCount = 0
|
||||
private set
|
||||
var postConsentCallCount = 0
|
||||
private set
|
||||
var lastPostedPayload: ConsentPayload? = null
|
||||
private set
|
||||
var lastFetchedSiteId: String? = null
|
||||
private set
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ConsentAPIProtocol
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
override suspend fun fetchConfig(siteId: String): ConsentConfig {
|
||||
fetchConfigCallCount++
|
||||
lastFetchedSiteId = siteId
|
||||
errorToThrow?.let { throw it }
|
||||
return configToReturn ?: throw ConsentAPIException.UnexpectedStatusCode(404)
|
||||
}
|
||||
|
||||
override suspend fun postConsent(payload: ConsentPayload) {
|
||||
postConsentCallCount++
|
||||
lastPostedPayload = payload
|
||||
postConsentError?.let { throw it }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.consentos
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for [ConsentCategory].
|
||||
*/
|
||||
class ConsentCategoryTest {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Raw Values
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `necessary has expected raw value`() {
|
||||
assertEquals("necessary", ConsentCategory.NECESSARY.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `functional has expected raw value`() {
|
||||
assertEquals("functional", ConsentCategory.FUNCTIONAL.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `analytics has expected raw value`() {
|
||||
assertEquals("analytics", ConsentCategory.ANALYTICS.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `marketing has expected raw value`() {
|
||||
assertEquals("marketing", ConsentCategory.MARKETING.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `personalisation has expected raw value`() {
|
||||
assertEquals("personalisation", ConsentCategory.PERSONALISATION.value)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// fromValue
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `fromValue returns correct category for known values`() {
|
||||
assertEquals(ConsentCategory.NECESSARY, ConsentCategory.fromValue("necessary"))
|
||||
assertEquals(ConsentCategory.FUNCTIONAL, ConsentCategory.fromValue("functional"))
|
||||
assertEquals(ConsentCategory.ANALYTICS, ConsentCategory.fromValue("analytics"))
|
||||
assertEquals(ConsentCategory.MARKETING, ConsentCategory.fromValue("marketing"))
|
||||
assertEquals(ConsentCategory.PERSONALISATION, ConsentCategory.fromValue("personalisation"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromValue returns null for unknown value`() {
|
||||
assertNull(ConsentCategory.fromValue("unknown_category"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromValue returns null for empty string`() {
|
||||
assertNull(ConsentCategory.fromValue(""))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// requiresConsent
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `necessary does not require consent`() {
|
||||
assertFalse(ConsentCategory.NECESSARY.requiresConsent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all non-necessary categories require consent`() {
|
||||
val nonNecessary = ConsentCategory.entries.filter { it != ConsentCategory.NECESSARY }
|
||||
nonNecessary.forEach { category ->
|
||||
assertTrue("$category should require consent", category.requiresConsent)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TCF Purpose IDs
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `necessary has no TCF purpose IDs`() {
|
||||
assertTrue(ConsentCategory.NECESSARY.tcfPurposeIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `functional maps to TCF purpose 1`() {
|
||||
assertEquals(listOf(1), ConsentCategory.FUNCTIONAL.tcfPurposeIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `analytics maps to TCF purposes 7 8 9 10`() {
|
||||
assertEquals(listOf(7, 8, 9, 10), ConsentCategory.ANALYTICS.tcfPurposeIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `marketing maps to TCF purposes 2 3 4`() {
|
||||
assertEquals(listOf(2, 3, 4), ConsentCategory.MARKETING.tcfPurposeIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `personalisation maps to TCF purposes 5 6`() {
|
||||
assertEquals(listOf(5, 6), ConsentCategory.PERSONALISATION.tcfPurposeIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all non-necessary categories have at least one TCF purpose ID`() {
|
||||
ConsentCategory.entries
|
||||
.filter { it.requiresConsent }
|
||||
.forEach { category ->
|
||||
assertTrue(
|
||||
"$category should have at least one TCF purpose ID",
|
||||
category.tcfPurposeIds.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GCM Consent Types
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `necessary has no GCM consent type`() {
|
||||
assertNull(ConsentCategory.NECESSARY.gcmConsentType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `functional maps to functionality_storage`() {
|
||||
assertEquals("functionality_storage", ConsentCategory.FUNCTIONAL.gcmConsentType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `analytics maps to analytics_storage`() {
|
||||
assertEquals("analytics_storage", ConsentCategory.ANALYTICS.gcmConsentType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `marketing maps to ad_storage`() {
|
||||
assertEquals("ad_storage", ConsentCategory.MARKETING.gcmConsentType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `personalisation maps to personalization_storage`() {
|
||||
assertEquals("personalization_storage", ConsentCategory.PERSONALISATION.gcmConsentType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all non-necessary categories have a GCM consent type`() {
|
||||
ConsentCategory.entries
|
||||
.filter { it.requiresConsent }
|
||||
.forEach { category ->
|
||||
assertNotNull("$category should have a GCM consent type", category.gcmConsentType)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Display
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `all categories have non-empty display names`() {
|
||||
ConsentCategory.entries.forEach { category ->
|
||||
assertTrue(
|
||||
"$category displayName should not be empty",
|
||||
category.displayName.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all categories have non-empty display descriptions`() {
|
||||
ConsentCategory.entries.forEach { category ->
|
||||
assertTrue(
|
||||
"$category displayDescription should not be empty",
|
||||
category.displayDescription.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `five categories are defined`() {
|
||||
assertEquals(5, ConsentCategory.entries.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package com.consentos
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Unit tests for [ConsentOS].
|
||||
*
|
||||
* Uses Robolectric for [Context] and mock dependencies to avoid real network / storage calls.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ConsentOSTest {
|
||||
|
||||
private lateinit var sdk: ConsentOS
|
||||
private lateinit var mockStorage: MockConsentStorage
|
||||
private lateinit var mockAPI: MockConsentAPI
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockStorage = MockConsentStorage()
|
||||
mockAPI = MockConsentAPI()
|
||||
sdk = ConsentOS(
|
||||
storage = mockStorage,
|
||||
api = mockAPI,
|
||||
gcmBridge = GCMBridge(NoOpGCMAnalyticsProvider()),
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// configure
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `configure sets isConfigured`() {
|
||||
assertFalse(sdk.isConfigured)
|
||||
sdk.siteId = "site-001" // Simulate configure without context
|
||||
assertTrue(sdk.isConfigured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `configure restores persisted state`() {
|
||||
val existing = ConsentState(
|
||||
visitorId = mockStorage.storedVisitorId,
|
||||
accepted = setOf(ConsentCategory.ANALYTICS),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
mockStorage.storedState = existing
|
||||
|
||||
// Manually wire up without a real Context (as configure() would do)
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = mockStorage.loadState() ?: ConsentState(mockStorage.visitorId())
|
||||
|
||||
assertEquals(setOf(ConsentCategory.ANALYTICS), sdk.consentState?.accepted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `configure creates new state when no persisted state exists`() {
|
||||
mockStorage.storedState = null
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = mockStorage.loadState() ?: ConsentState(mockStorage.visitorId())
|
||||
|
||||
assertNotNull(sdk.consentState)
|
||||
assertFalse(sdk.consentState!!.hasInteracted)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// shouldShowBanner
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `shouldShowBanner returns true when no interaction`() = runTest {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1") // no interaction
|
||||
|
||||
assertTrue(sdk.shouldShowBanner())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shouldShowBanner returns false when recent consent exists`() = runTest {
|
||||
mockAPI.configToReturn = makeSampleConfig(expiryDays = 365, bannerVersion = "v1")
|
||||
mockStorage.storedCachedConfig = CachedConfig(
|
||||
config = makeSampleConfig(expiryDays = 365, bannerVersion = "v1"),
|
||||
fetchedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(
|
||||
visitorId = "v1",
|
||||
accepted = setOf(ConsentCategory.ANALYTICS),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
bannerVersion = "v1",
|
||||
)
|
||||
sdk.siteConfig = mockStorage.storedCachedConfig!!.config
|
||||
|
||||
assertFalse(sdk.shouldShowBanner())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shouldShowBanner returns true when banner version changed`() = runTest {
|
||||
mockAPI.configToReturn = makeSampleConfig(expiryDays = 365, bannerVersion = "v2")
|
||||
mockStorage.storedCachedConfig = CachedConfig(
|
||||
config = makeSampleConfig(expiryDays = 365, bannerVersion = "v2"),
|
||||
fetchedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(
|
||||
visitorId = "v1",
|
||||
accepted = setOf(ConsentCategory.ANALYTICS),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
bannerVersion = "v1", // old version
|
||||
)
|
||||
sdk.siteConfig = mockStorage.storedCachedConfig!!.config
|
||||
|
||||
assertTrue(sdk.shouldShowBanner())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shouldShowBanner returns true when consent has expired`() = runTest {
|
||||
val expiryDays = 30
|
||||
val expiredTs = System.currentTimeMillis() - (expiryDays * 86_400_000L + 1_000L)
|
||||
|
||||
mockStorage.storedCachedConfig = CachedConfig(
|
||||
config = makeSampleConfig(expiryDays = expiryDays, bannerVersion = "v1"),
|
||||
fetchedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(
|
||||
visitorId = "v1",
|
||||
accepted = setOf(ConsentCategory.ANALYTICS),
|
||||
consentedAtMs = expiredTs,
|
||||
bannerVersion = "v1",
|
||||
)
|
||||
sdk.siteConfig = mockStorage.storedCachedConfig!!.config
|
||||
|
||||
assertTrue(sdk.shouldShowBanner())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// acceptAll
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `acceptAll updates consent state`() = runTest {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
|
||||
sdk.acceptAll()
|
||||
|
||||
val state = sdk.consentState
|
||||
assertNotNull(state)
|
||||
assertTrue(state!!.hasInteracted)
|
||||
assertTrue(state.accepted.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acceptAll grants all optional categories`() = runTest {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
|
||||
sdk.acceptAll()
|
||||
|
||||
ConsentCategory.entries
|
||||
.filter { it.requiresConsent }
|
||||
.forEach { category ->
|
||||
assertTrue("$category should be granted", sdk.getConsentStatus(category))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acceptAll persists to storage`() = runTest {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
|
||||
sdk.acceptAll()
|
||||
|
||||
assertTrue(mockStorage.saveStateCallCount > 0)
|
||||
assertNotNull(mockStorage.storedState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acceptAll posts consent to API`() = runTest {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
|
||||
sdk.acceptAll()
|
||||
|
||||
assertEquals(1, mockAPI.postConsentCallCount)
|
||||
assertEquals("android", mockAPI.lastPostedPayload?.platform)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// rejectAll
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `rejectAll updates consent state`() = runTest {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
|
||||
sdk.rejectAll()
|
||||
|
||||
val state = sdk.consentState
|
||||
assertNotNull(state)
|
||||
assertTrue(state!!.hasInteracted)
|
||||
assertTrue(state.accepted.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejectAll denies all optional categories`() = runTest {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
|
||||
sdk.rejectAll()
|
||||
|
||||
ConsentCategory.entries
|
||||
.filter { it.requiresConsent }
|
||||
.forEach { category ->
|
||||
assertFalse("$category should be denied", sdk.getConsentStatus(category))
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// acceptCategories
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `acceptCategories only grants specified categories`() = runTest {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
|
||||
sdk.acceptCategories(listOf(ConsentCategory.ANALYTICS, ConsentCategory.FUNCTIONAL))
|
||||
|
||||
assertTrue(sdk.getConsentStatus(ConsentCategory.ANALYTICS))
|
||||
assertTrue(sdk.getConsentStatus(ConsentCategory.FUNCTIONAL))
|
||||
assertFalse(sdk.getConsentStatus(ConsentCategory.MARKETING))
|
||||
assertFalse(sdk.getConsentStatus(ConsentCategory.PERSONALISATION))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// getConsentStatus
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `getConsentStatus returns true for necessary always`() {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
assertTrue(sdk.getConsentStatus(ConsentCategory.NECESSARY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConsentStatus returns false for optional before interaction`() {
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
assertFalse(sdk.getConsentStatus(ConsentCategory.ANALYTICS))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Listener
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `listener is notified after acceptAll`() = runTest {
|
||||
var notified = false
|
||||
var receivedState: ConsentState? = null
|
||||
|
||||
val listener = object : ConsentOSListener {
|
||||
override fun onConsentChanged(state: ConsentState) {
|
||||
notified = true
|
||||
receivedState = state
|
||||
}
|
||||
}
|
||||
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
sdk.addConsentListener(listener)
|
||||
|
||||
sdk.acceptAll()
|
||||
|
||||
assertTrue(notified)
|
||||
assertNotNull(receivedState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listener is not notified after removal`() = runTest {
|
||||
var callCount = 0
|
||||
val listener = object : ConsentOSListener {
|
||||
override fun onConsentChanged(state: ConsentState) {
|
||||
callCount++
|
||||
}
|
||||
}
|
||||
|
||||
sdk.siteId = "site-001"
|
||||
sdk.consentState = ConsentState(visitorId = "v1")
|
||||
sdk.addConsentListener(listener)
|
||||
sdk.removeConsentListener(listener)
|
||||
|
||||
sdk.acceptAll()
|
||||
|
||||
assertEquals(0, callCount)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun makeSampleConfig(expiryDays: Int = 365, bannerVersion: String = "v1") =
|
||||
ConsentConfig(
|
||||
siteId = "site-001",
|
||||
siteName = "Test",
|
||||
blockingMode = ConsentConfig.BlockingMode.OPT_IN,
|
||||
consentExpiryDays = expiryDays,
|
||||
bannerVersion = bannerVersion,
|
||||
bannerConfig = ConsentConfig.BannerConfig(),
|
||||
categories = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MockConsentStorage
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* In-memory [ConsentStorageProtocol] implementation for testing.
|
||||
*/
|
||||
class MockConsentStorage : ConsentStorageProtocol {
|
||||
|
||||
var storedState: ConsentState? = null
|
||||
var storedCachedConfig: CachedConfig? = null
|
||||
val storedVisitorId: String = "mock-visitor-${System.currentTimeMillis()}"
|
||||
|
||||
var saveStateCallCount = 0
|
||||
private set
|
||||
var clearStateCallCount = 0
|
||||
private set
|
||||
|
||||
override fun loadState(): ConsentState? = storedState
|
||||
|
||||
override fun saveState(state: ConsentState) {
|
||||
saveStateCallCount++
|
||||
storedState = state
|
||||
}
|
||||
|
||||
override fun clearState() {
|
||||
clearStateCallCount++
|
||||
storedState = null
|
||||
}
|
||||
|
||||
override fun loadCachedConfig(): CachedConfig? = storedCachedConfig
|
||||
|
||||
override fun saveCachedConfig(cached: CachedConfig) {
|
||||
storedCachedConfig = cached
|
||||
}
|
||||
|
||||
override fun clearCachedConfig() {
|
||||
storedCachedConfig = null
|
||||
}
|
||||
|
||||
override fun visitorId(): String = storedVisitorId
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package com.consentos
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for [ConsentState].
|
||||
*/
|
||||
class ConsentStateTest {
|
||||
|
||||
private val visitorId = "test-visitor-123"
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initial State
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `new state has no interaction`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
assertFalse(state.hasInteracted)
|
||||
assertNull(state.consentedAtMs)
|
||||
assertTrue(state.accepted.isEmpty())
|
||||
assertTrue(state.rejected.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new state preserves visitor ID`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
assertEquals(visitorId, state.visitorId)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// isGranted
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `necessary is always granted`() {
|
||||
val state = ConsentState(visitorId = visitorId) // no interaction
|
||||
assertTrue(state.isGranted(ConsentCategory.NECESSARY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `optional categories are not granted when no interaction`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
ConsentCategory.entries
|
||||
.filter { it != ConsentCategory.NECESSARY }
|
||||
.forEach { category ->
|
||||
assertFalse("$category should not be granted by default", state.isGranted(category))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepted category is granted`() {
|
||||
val state = ConsentState(
|
||||
visitorId = visitorId,
|
||||
accepted = setOf(ConsentCategory.ANALYTICS),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
assertTrue(state.isGranted(ConsentCategory.ANALYTICS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected category is not granted`() {
|
||||
val state = ConsentState(
|
||||
visitorId = visitorId,
|
||||
rejected = setOf(ConsentCategory.ANALYTICS),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
assertFalse(state.isGranted(ConsentCategory.ANALYTICS))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// isDenied
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `necessary is never denied`() {
|
||||
val state = ConsentState(
|
||||
visitorId = visitorId,
|
||||
rejected = setOf(ConsentCategory.ANALYTICS),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
assertFalse(state.isDenied(ConsentCategory.NECESSARY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected category is denied`() {
|
||||
val state = ConsentState(
|
||||
visitorId = visitorId,
|
||||
rejected = setOf(ConsentCategory.MARKETING),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
assertTrue(state.isDenied(ConsentCategory.MARKETING))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-rejected category is not denied`() {
|
||||
val state = ConsentState(
|
||||
visitorId = visitorId,
|
||||
accepted = setOf(ConsentCategory.ANALYTICS),
|
||||
consentedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
assertFalse(state.isDenied(ConsentCategory.ANALYTICS))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// acceptingAll()
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `acceptingAll grants all optional categories`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
val accepted = state.acceptingAll()
|
||||
|
||||
val expected = ConsentCategory.entries.filter { it.requiresConsent }.toSet()
|
||||
assertEquals(expected, accepted.accepted)
|
||||
assertTrue(accepted.rejected.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acceptingAll sets consentedAtMs`() {
|
||||
val before = System.currentTimeMillis()
|
||||
val state = ConsentState(visitorId = visitorId).acceptingAll()
|
||||
assertNotNull(state.consentedAtMs)
|
||||
assertTrue(state.consentedAtMs!! >= before)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acceptingAll preserves visitor ID`() {
|
||||
val state = ConsentState(visitorId = visitorId).acceptingAll()
|
||||
assertEquals(visitorId, state.visitorId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acceptingAll marks state as interacted`() {
|
||||
val state = ConsentState(visitorId = visitorId).acceptingAll()
|
||||
assertTrue(state.hasInteracted)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// rejectingAll()
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `rejectingAll empties accepted set`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
val rejected = state.rejectingAll()
|
||||
assertTrue(rejected.accepted.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejectingAll rejects all optional categories`() {
|
||||
val state = ConsentState(visitorId = visitorId).rejectingAll()
|
||||
val expected = ConsentCategory.entries.filter { it.requiresConsent }.toSet()
|
||||
assertEquals(expected, state.rejected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejectingAll sets consentedAtMs`() {
|
||||
val state = ConsentState(visitorId = visitorId).rejectingAll()
|
||||
assertNotNull(state.consentedAtMs)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// accepting(categories)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `accepting only accepts specified categories`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
val result = state.accepting(setOf(ConsentCategory.ANALYTICS, ConsentCategory.FUNCTIONAL))
|
||||
|
||||
assertTrue(result.accepted.contains(ConsentCategory.ANALYTICS))
|
||||
assertTrue(result.accepted.contains(ConsentCategory.FUNCTIONAL))
|
||||
assertFalse(result.accepted.contains(ConsentCategory.MARKETING))
|
||||
assertFalse(result.accepted.contains(ConsentCategory.PERSONALISATION))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepting rejects remainder`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
val result = state.accepting(setOf(ConsentCategory.ANALYTICS))
|
||||
|
||||
assertTrue(result.rejected.contains(ConsentCategory.MARKETING))
|
||||
assertTrue(result.rejected.contains(ConsentCategory.FUNCTIONAL))
|
||||
assertTrue(result.rejected.contains(ConsentCategory.PERSONALISATION))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepting ignores necessary category`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
val result = state.accepting(setOf(ConsentCategory.NECESSARY))
|
||||
assertFalse(result.accepted.contains(ConsentCategory.NECESSARY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepting empty set rejects all`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
val result = state.accepting(emptySet())
|
||||
assertTrue(result.accepted.isEmpty())
|
||||
val expectedRejected = ConsentCategory.entries.filter { it.requiresConsent }.toSet()
|
||||
assertEquals(expectedRejected, result.rejected)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Serialisation (kotlinx.serialization)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `state round-trips via JSON`() {
|
||||
val original = ConsentState(
|
||||
visitorId = visitorId,
|
||||
accepted = setOf(ConsentCategory.ANALYTICS, ConsentCategory.FUNCTIONAL),
|
||||
rejected = setOf(ConsentCategory.MARKETING),
|
||||
consentedAtMs = 1_700_000_000_000L,
|
||||
bannerVersion = "v2",
|
||||
)
|
||||
|
||||
val encoded = json.encodeToString(original)
|
||||
val decoded = json.decodeFromString<ConsentState>(encoded)
|
||||
|
||||
assertEquals(original.visitorId, decoded.visitorId)
|
||||
assertEquals(original.accepted, decoded.accepted)
|
||||
assertEquals(original.rejected, decoded.rejected)
|
||||
assertEquals(original.consentedAtMs, decoded.consentedAtMs)
|
||||
assertEquals(original.bannerVersion, decoded.bannerVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty state serialises without error`() {
|
||||
val state = ConsentState(visitorId = visitorId)
|
||||
val encoded = json.encodeToString(state)
|
||||
assertNotNull(encoded)
|
||||
assertTrue(encoded.isNotEmpty())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Equality
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `two states with same values are equal`() {
|
||||
val ts = 1_000_000L
|
||||
val a = ConsentState(visitorId, setOf(ConsentCategory.ANALYTICS), emptySet(), ts, "v1")
|
||||
val b = ConsentState(visitorId, setOf(ConsentCategory.ANALYTICS), emptySet(), ts, "v1")
|
||||
assertEquals(a, b)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two states with different accepted are not equal`() {
|
||||
val ts = 1_000_000L
|
||||
val a = ConsentState(visitorId, setOf(ConsentCategory.ANALYTICS), emptySet(), ts, null)
|
||||
val b = ConsentState(visitorId, setOf(ConsentCategory.MARKETING), emptySet(), ts, null)
|
||||
assertTrue(a != b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.consentos
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Unit tests for [ConsentStorage].
|
||||
*
|
||||
* Robolectric is used to provide an Android [Context] and emulate
|
||||
* [EncryptedSharedPreferences] without a device or emulator.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ConsentStorageTest {
|
||||
|
||||
private lateinit var storage: ConsentStorage
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
storage = ConsentStorage(context)
|
||||
// Wipe state between tests.
|
||||
storage.clearState()
|
||||
storage.clearCachedConfig()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ConsentState
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `loadState returns null when no state is stored`() {
|
||||
assertNull(storage.loadState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveState and loadState round-trip a ConsentState`() {
|
||||
val original = ConsentState(
|
||||
visitorId = "visitor-abc",
|
||||
accepted = setOf(ConsentCategory.ANALYTICS, ConsentCategory.FUNCTIONAL),
|
||||
rejected = setOf(ConsentCategory.MARKETING),
|
||||
consentedAtMs = 1_700_000_000_000L,
|
||||
bannerVersion = "v2",
|
||||
)
|
||||
|
||||
storage.saveState(original)
|
||||
val loaded = storage.loadState()
|
||||
|
||||
assertNotNull(loaded)
|
||||
assertEquals(original.visitorId, loaded!!.visitorId)
|
||||
assertEquals(original.accepted, loaded.accepted)
|
||||
assertEquals(original.rejected, loaded.rejected)
|
||||
assertEquals(original.consentedAtMs, loaded.consentedAtMs)
|
||||
assertEquals(original.bannerVersion, loaded.bannerVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearState removes stored state`() {
|
||||
val state = ConsentState(visitorId = "v1").acceptingAll()
|
||||
storage.saveState(state)
|
||||
storage.clearState()
|
||||
assertNull(storage.loadState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveState overwrites previous state`() {
|
||||
val first = ConsentState(visitorId = "v1").acceptingAll()
|
||||
val second = ConsentState(visitorId = "v1").rejectingAll()
|
||||
|
||||
storage.saveState(first)
|
||||
storage.saveState(second)
|
||||
|
||||
val loaded = storage.loadState()
|
||||
assertNotNull(loaded)
|
||||
assertTrue(loaded!!.accepted.isEmpty())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CachedConfig
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `loadCachedConfig returns null when nothing is stored`() {
|
||||
assertNull(storage.loadCachedConfig())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveCachedConfig and loadCachedConfig round-trip a CachedConfig`() {
|
||||
val config = makeSampleConfig()
|
||||
val cached = CachedConfig(config = config, fetchedAtMs = 1_700_000_000_000L)
|
||||
|
||||
storage.saveCachedConfig(cached)
|
||||
val loaded = storage.loadCachedConfig()
|
||||
|
||||
assertNotNull(loaded)
|
||||
assertEquals(cached.config.siteId, loaded!!.config.siteId)
|
||||
assertEquals(cached.fetchedAtMs, loaded.fetchedAtMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearCachedConfig removes stored config`() {
|
||||
val config = makeSampleConfig()
|
||||
storage.saveCachedConfig(CachedConfig(config = config, fetchedAtMs = System.currentTimeMillis()))
|
||||
storage.clearCachedConfig()
|
||||
assertNull(storage.loadCachedConfig())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Visitor ID
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `visitorId returns a non-empty UUID-like string`() {
|
||||
val id = storage.visitorId()
|
||||
assertNotNull(id)
|
||||
assertTrue(id.isNotEmpty())
|
||||
// UUIDs are 36 characters: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
assertEquals(36, id.length)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visitorId returns the same value on subsequent calls`() {
|
||||
val first = storage.visitorId()
|
||||
val second = storage.visitorId()
|
||||
assertEquals(first, second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two distinct storage instances share the same visitor ID`() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val storage2 = ConsentStorage(context)
|
||||
val id1 = storage.visitorId()
|
||||
val id2 = storage2.visitorId()
|
||||
assertEquals(id1, id2)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CachedConfig Expiry
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `CachedConfig is not expired when freshly created`() {
|
||||
val cached = CachedConfig(
|
||||
config = makeSampleConfig(),
|
||||
fetchedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
assertTrue(!cached.isExpired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CachedConfig is expired when older than TTL`() {
|
||||
val tooOldMs = System.currentTimeMillis() - CachedConfig.TTL_MS - 1_000L
|
||||
val cached = CachedConfig(
|
||||
config = makeSampleConfig(),
|
||||
fetchedAtMs = tooOldMs,
|
||||
)
|
||||
assertTrue(cached.isExpired)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun makeSampleConfig() = ConsentConfig(
|
||||
siteId = "site-001",
|
||||
siteName = "Test Site",
|
||||
blockingMode = ConsentConfig.BlockingMode.OPT_IN,
|
||||
consentExpiryDays = 365,
|
||||
bannerVersion = "v1",
|
||||
bannerConfig = ConsentConfig.BannerConfig(),
|
||||
categories = emptyList(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.consentos
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.Base64
|
||||
|
||||
/**
|
||||
* Unit tests for [TCFStringEncoder].
|
||||
*
|
||||
* Robolectric is used here because [TCFStringEncoder] uses [android.util.Base64].
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class TCFStringEncoderTest {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Returns null before interaction
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `encode returns null when state has no interaction`() {
|
||||
val state = ConsentState(visitorId = "v1") // consentedAtMs is null
|
||||
val config = makeSampleConfig()
|
||||
assertNull(TCFStringEncoder.encode(state, config))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Returns a non-empty string after interaction
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `encode returns non-empty string after acceptAll`() {
|
||||
val state = ConsentState(visitorId = "v1").acceptingAll()
|
||||
val config = makeSampleConfig()
|
||||
val result = TCFStringEncoder.encode(state, config)
|
||||
assertNotNull(result)
|
||||
assertTrue(result!!.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encode returns non-empty string after rejectAll`() {
|
||||
val state = ConsentState(visitorId = "v1").rejectingAll()
|
||||
val config = makeSampleConfig()
|
||||
val result = TCFStringEncoder.encode(state, config)
|
||||
assertNotNull(result)
|
||||
assertTrue(result!!.isNotEmpty())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Base64url format
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `encode produces valid Base64url without plus signs`() {
|
||||
val state = ConsentState(visitorId = "v1").acceptingAll()
|
||||
val tcString = TCFStringEncoder.encode(state, makeSampleConfig())!!
|
||||
assertFalse("TC string must not contain '+'", tcString.contains('+'))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encode produces valid Base64url without forward slashes`() {
|
||||
val state = ConsentState(visitorId = "v1").acceptingAll()
|
||||
val tcString = TCFStringEncoder.encode(state, makeSampleConfig())!!
|
||||
assertFalse("TC string must not contain '/'", tcString.contains('/'))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encode produces valid Base64url without padding`() {
|
||||
val state = ConsentState(visitorId = "v1").acceptingAll()
|
||||
val tcString = TCFStringEncoder.encode(state, makeSampleConfig())!!
|
||||
assertFalse("TC string must not contain '='", tcString.contains('='))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encode produces decodable Base64`() {
|
||||
val state = ConsentState(visitorId = "v1").acceptingAll()
|
||||
val tcString = TCFStringEncoder.encode(state, makeSampleConfig())!!
|
||||
|
||||
// Convert Base64url back to standard Base64 with padding for decoding.
|
||||
var standard = tcString
|
||||
.replace('-', '+')
|
||||
.replace('_', '/')
|
||||
val remainder = standard.length % 4
|
||||
if (remainder != 0) standard += "=".repeat(4 - remainder)
|
||||
|
||||
val data = Base64.getDecoder().decode(standard)
|
||||
assertNotNull(data)
|
||||
assertTrue(data.isNotEmpty())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Determinism
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `encode produces identical output for identical input`() {
|
||||
val ts = 1_700_000_000_000L
|
||||
val state = ConsentState(
|
||||
visitorId = "v1",
|
||||
accepted = setOf(ConsentCategory.ANALYTICS, ConsentCategory.MARKETING),
|
||||
rejected = setOf(ConsentCategory.FUNCTIONAL, ConsentCategory.PERSONALISATION),
|
||||
consentedAtMs = ts,
|
||||
bannerVersion = "v1",
|
||||
)
|
||||
val config = makeSampleConfig()
|
||||
|
||||
val result1 = TCFStringEncoder.encode(state, config)
|
||||
val result2 = TCFStringEncoder.encode(state, config)
|
||||
|
||||
assertEquals(result1, result2)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Different states produce different strings
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `encode produces distinct strings for acceptAll vs rejectAll`() {
|
||||
val ts = 1_700_000_000_000L
|
||||
val config = makeSampleConfig()
|
||||
|
||||
val acceptedState = ConsentState(
|
||||
visitorId = "v1",
|
||||
accepted = ConsentCategory.entries.filter { it.requiresConsent }.toSet(),
|
||||
consentedAtMs = ts,
|
||||
)
|
||||
val rejectedState = ConsentState(
|
||||
visitorId = "v1",
|
||||
rejected = ConsentCategory.entries.filter { it.requiresConsent }.toSet(),
|
||||
consentedAtMs = ts,
|
||||
)
|
||||
|
||||
val tcAccepted = TCFStringEncoder.encode(acceptedState, config)
|
||||
val tcRejected = TCFStringEncoder.encode(rejectedState, config)
|
||||
|
||||
assertNotEquals(tcAccepted, tcRejected)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Minimum length
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `encode produces string of reasonable length`() {
|
||||
val state = ConsentState(visitorId = "v1").acceptingAll()
|
||||
val tcString = TCFStringEncoder.encode(state, makeSampleConfig())!!
|
||||
|
||||
// A valid core TC string serialises to at least ~20 bytes before Base64url encoding.
|
||||
// 28 chars is a conservative lower bound.
|
||||
assertTrue("TC string appears too short: ${tcString.length}", tcString.length > 28)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun makeSampleConfig() = ConsentConfig(
|
||||
siteId = "site-001",
|
||||
siteName = "Test Site",
|
||||
blockingMode = ConsentConfig.BlockingMode.OPT_IN,
|
||||
consentExpiryDays = 365,
|
||||
bannerVersion = "v1",
|
||||
bannerConfig = ConsentConfig.BannerConfig(),
|
||||
categories = emptyList(),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user