4435e7ea0a
Services: Admin-Compliance, Backend-Compliance, AI-Compliance-SDK, Consent-SDK, Developer-Portal, PCA-Platform, DSMS Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
500 lines
15 KiB
Kotlin
500 lines
15 KiB
Kotlin
/**
|
|
* Android Consent SDK - ConsentManager
|
|
*
|
|
* DSGVO/TTDSG-konformes Consent Management fuer Android Apps.
|
|
*
|
|
* Nutzung:
|
|
* 1. In Application.onCreate() konfigurieren
|
|
* 2. In Activities/Fragments mit ConsentManager.current nutzen
|
|
* 3. In Jetpack Compose mit rememberConsentState()
|
|
*
|
|
* Copyright (c) 2025 BreakPilot
|
|
* Apache License 2.0
|
|
*/
|
|
|
|
package com.breakpilot.consent
|
|
|
|
import android.content.Context
|
|
import android.content.SharedPreferences
|
|
import android.os.Build
|
|
import android.provider.Settings
|
|
import androidx.compose.runtime.*
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.security.crypto.EncryptedSharedPreferences
|
|
import androidx.security.crypto.MasterKey
|
|
import kotlinx.coroutines.*
|
|
import kotlinx.coroutines.flow.*
|
|
import kotlinx.serialization.Serializable
|
|
import kotlinx.serialization.encodeToString
|
|
import kotlinx.serialization.json.Json
|
|
import okhttp3.*
|
|
import okhttp3.MediaType.Companion.toMediaType
|
|
import okhttp3.RequestBody.Companion.toRequestBody
|
|
import java.security.MessageDigest
|
|
import java.util.*
|
|
|
|
// =============================================================================
|
|
// Consent Categories
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Standard-Consent-Kategorien nach IAB TCF 2.2
|
|
*/
|
|
enum class ConsentCategory {
|
|
ESSENTIAL, // Technisch notwendig
|
|
FUNCTIONAL, // Personalisierung
|
|
ANALYTICS, // Nutzungsanalyse
|
|
MARKETING, // Werbung
|
|
SOCIAL // Social Media
|
|
}
|
|
|
|
// =============================================================================
|
|
// Consent State
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Aktueller Consent-Zustand
|
|
*/
|
|
@Serializable
|
|
data class ConsentState(
|
|
val categories: Map<ConsentCategory, Boolean> = defaultCategories(),
|
|
val vendors: Map<String, Boolean> = emptyMap(),
|
|
val timestamp: Long = System.currentTimeMillis(),
|
|
val version: String = "1.0.0",
|
|
val consentId: String? = null,
|
|
val expiresAt: Long? = null,
|
|
val tcfString: String? = null
|
|
) {
|
|
companion object {
|
|
fun defaultCategories() = mapOf(
|
|
ConsentCategory.ESSENTIAL to true,
|
|
ConsentCategory.FUNCTIONAL to false,
|
|
ConsentCategory.ANALYTICS to false,
|
|
ConsentCategory.MARKETING to false,
|
|
ConsentCategory.SOCIAL to false
|
|
)
|
|
|
|
val DEFAULT = ConsentState()
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Configuration
|
|
// =============================================================================
|
|
|
|
/**
|
|
* SDK-Konfiguration
|
|
*/
|
|
data class ConsentConfig(
|
|
val apiEndpoint: String,
|
|
val siteId: String,
|
|
val language: String = Locale.getDefault().language,
|
|
val showRejectAll: Boolean = true,
|
|
val showAcceptAll: Boolean = true,
|
|
val granularControl: Boolean = true,
|
|
val rememberDays: Int = 365,
|
|
val debug: Boolean = false
|
|
)
|
|
|
|
// =============================================================================
|
|
// Consent Manager
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Haupt-Manager fuer Consent-Verwaltung
|
|
*/
|
|
class ConsentManager private constructor() {
|
|
|
|
// State
|
|
private val _consent = MutableStateFlow(ConsentState.DEFAULT)
|
|
val consent: StateFlow<ConsentState> = _consent.asStateFlow()
|
|
|
|
private val _isInitialized = MutableStateFlow(false)
|
|
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
|
|
|
|
private val _isLoading = MutableStateFlow(true)
|
|
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
|
|
|
private val _isBannerVisible = MutableStateFlow(false)
|
|
val isBannerVisible: StateFlow<Boolean> = _isBannerVisible.asStateFlow()
|
|
|
|
private val _isSettingsVisible = MutableStateFlow(false)
|
|
val isSettingsVisible: StateFlow<Boolean> = _isSettingsVisible.asStateFlow()
|
|
|
|
// Private
|
|
private var config: ConsentConfig? = null
|
|
private var storage: ConsentStorage? = null
|
|
private var apiClient: ConsentApiClient? = null
|
|
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
|
|
|
// Singleton
|
|
companion object {
|
|
@Volatile
|
|
private var INSTANCE: ConsentManager? = null
|
|
|
|
val current: ConsentManager
|
|
get() = INSTANCE ?: synchronized(this) {
|
|
INSTANCE ?: ConsentManager().also { INSTANCE = it }
|
|
}
|
|
|
|
/**
|
|
* Konfiguriert den ConsentManager
|
|
* Sollte in Application.onCreate() aufgerufen werden
|
|
*/
|
|
fun configure(context: Context, config: ConsentConfig) {
|
|
current.apply {
|
|
this.config = config
|
|
this.storage = ConsentStorage(context)
|
|
this.apiClient = ConsentApiClient(config.apiEndpoint, config.siteId)
|
|
|
|
if (config.debug) {
|
|
println("[ConsentSDK] Configured with siteId: ${config.siteId}")
|
|
}
|
|
|
|
scope.launch {
|
|
initialize(context)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Initialization
|
|
// ==========================================================================
|
|
|
|
private suspend fun initialize(context: Context) {
|
|
try {
|
|
// Lokalen Consent laden
|
|
storage?.load()?.let { stored ->
|
|
// Pruefen ob abgelaufen
|
|
val expiresAt = stored.expiresAt
|
|
if (expiresAt != null && System.currentTimeMillis() > expiresAt) {
|
|
_consent.value = ConsentState.DEFAULT
|
|
storage?.clear()
|
|
} else {
|
|
_consent.value = stored
|
|
}
|
|
}
|
|
|
|
// Vom Server synchronisieren
|
|
try {
|
|
apiClient?.getConsent(DeviceFingerprint.generate(context))?.let { serverConsent ->
|
|
_consent.value = serverConsent
|
|
storage?.save(serverConsent)
|
|
}
|
|
} catch (e: Exception) {
|
|
if (config?.debug == true) {
|
|
println("[ConsentSDK] Failed to sync consent: $e")
|
|
}
|
|
}
|
|
|
|
_isInitialized.value = true
|
|
|
|
// Banner anzeigen falls noetig
|
|
if (needsConsent) {
|
|
showBanner()
|
|
}
|
|
} finally {
|
|
_isLoading.value = false
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Public API
|
|
// ==========================================================================
|
|
|
|
/**
|
|
* Prueft ob Consent fuer Kategorie erteilt wurde
|
|
*/
|
|
fun hasConsent(category: ConsentCategory): Boolean {
|
|
// Essential ist immer erlaubt
|
|
if (category == ConsentCategory.ESSENTIAL) return true
|
|
return consent.value.categories[category] ?: false
|
|
}
|
|
|
|
/**
|
|
* Prueft ob Consent eingeholt werden muss
|
|
*/
|
|
val needsConsent: Boolean
|
|
get() = consent.value.consentId == null
|
|
|
|
/**
|
|
* Alle Kategorien akzeptieren
|
|
*/
|
|
suspend fun acceptAll() {
|
|
val newCategories = ConsentCategory.values().associateWith { true }
|
|
val newConsent = consent.value.copy(
|
|
categories = newCategories,
|
|
timestamp = System.currentTimeMillis()
|
|
)
|
|
saveConsent(newConsent)
|
|
hideBanner()
|
|
}
|
|
|
|
/**
|
|
* Alle nicht-essentiellen Kategorien ablehnen
|
|
*/
|
|
suspend fun rejectAll() {
|
|
val newCategories = ConsentCategory.values().associateWith {
|
|
it == ConsentCategory.ESSENTIAL
|
|
}
|
|
val newConsent = consent.value.copy(
|
|
categories = newCategories,
|
|
timestamp = System.currentTimeMillis()
|
|
)
|
|
saveConsent(newConsent)
|
|
hideBanner()
|
|
}
|
|
|
|
/**
|
|
* Auswahl speichern
|
|
*/
|
|
suspend fun saveSelection(categories: Map<ConsentCategory, Boolean>) {
|
|
val updated = categories.toMutableMap()
|
|
updated[ConsentCategory.ESSENTIAL] = true // Essential immer true
|
|
val newConsent = consent.value.copy(
|
|
categories = updated,
|
|
timestamp = System.currentTimeMillis()
|
|
)
|
|
saveConsent(newConsent)
|
|
hideBanner()
|
|
}
|
|
|
|
// ==========================================================================
|
|
// UI Control
|
|
// ==========================================================================
|
|
|
|
fun showBanner() {
|
|
_isBannerVisible.value = true
|
|
}
|
|
|
|
fun hideBanner() {
|
|
_isBannerVisible.value = false
|
|
}
|
|
|
|
fun showSettings() {
|
|
_isSettingsVisible.value = true
|
|
}
|
|
|
|
fun hideSettings() {
|
|
_isSettingsVisible.value = false
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Private Methods
|
|
// ==========================================================================
|
|
|
|
private suspend fun saveConsent(newConsent: ConsentState) {
|
|
// Lokal speichern
|
|
storage?.save(newConsent)
|
|
|
|
// An Server senden
|
|
try {
|
|
val response = apiClient?.saveConsent(
|
|
newConsent,
|
|
DeviceFingerprint.generate(storage?.context!!)
|
|
)
|
|
val updated = newConsent.copy(
|
|
consentId = response?.consentId,
|
|
expiresAt = response?.expiresAt
|
|
)
|
|
_consent.value = updated
|
|
storage?.save(updated)
|
|
} catch (e: Exception) {
|
|
// Lokal speichern auch bei Fehler
|
|
_consent.value = newConsent
|
|
if (config?.debug == true) {
|
|
println("[ConsentSDK] Failed to sync consent: $e")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Storage
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Sichere Speicherung mit EncryptedSharedPreferences
|
|
*/
|
|
internal class ConsentStorage(val context: Context) {
|
|
private val prefs: SharedPreferences by lazy {
|
|
val masterKey = MasterKey.Builder(context)
|
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
|
.build()
|
|
|
|
EncryptedSharedPreferences.create(
|
|
context,
|
|
"breakpilot_consent",
|
|
masterKey,
|
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
|
)
|
|
}
|
|
|
|
private val json = Json { ignoreUnknownKeys = true }
|
|
|
|
fun load(): ConsentState? {
|
|
val data = prefs.getString("consent_state", null) ?: return null
|
|
return try {
|
|
json.decodeFromString<ConsentState>(data)
|
|
} catch (e: Exception) {
|
|
null
|
|
}
|
|
}
|
|
|
|
fun save(consent: ConsentState) {
|
|
val data = json.encodeToString(consent)
|
|
prefs.edit().putString("consent_state", data).apply()
|
|
}
|
|
|
|
fun clear() {
|
|
prefs.edit().remove("consent_state").apply()
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// API Client
|
|
// =============================================================================
|
|
|
|
/**
|
|
* API Client fuer Backend-Kommunikation
|
|
*/
|
|
internal class ConsentApiClient(
|
|
private val baseUrl: String,
|
|
private val siteId: String
|
|
) {
|
|
private val client = OkHttpClient()
|
|
private val json = Json { ignoreUnknownKeys = true }
|
|
|
|
@Serializable
|
|
data class ConsentResponse(
|
|
val consentId: String,
|
|
val expiresAt: Long
|
|
)
|
|
|
|
suspend fun getConsent(fingerprint: String): ConsentState? = withContext(Dispatchers.IO) {
|
|
val request = Request.Builder()
|
|
.url("$baseUrl/banner/consent?site_id=$siteId&fingerprint=$fingerprint")
|
|
.get()
|
|
.build()
|
|
|
|
client.newCall(request).execute().use { response ->
|
|
if (!response.isSuccessful) return@withContext null
|
|
val body = response.body?.string() ?: return@withContext null
|
|
json.decodeFromString<ConsentState>(body)
|
|
}
|
|
}
|
|
|
|
suspend fun saveConsent(
|
|
consent: ConsentState,
|
|
fingerprint: String
|
|
): ConsentResponse = withContext(Dispatchers.IO) {
|
|
val body = """
|
|
{
|
|
"site_id": "$siteId",
|
|
"device_fingerprint": "$fingerprint",
|
|
"categories": ${json.encodeToString(consent.categories.mapKeys { it.key.name.lowercase() })},
|
|
"vendors": ${json.encodeToString(consent.vendors)},
|
|
"platform": "android",
|
|
"app_version": "${BuildConfig.VERSION_NAME}"
|
|
}
|
|
""".trimIndent()
|
|
|
|
val request = Request.Builder()
|
|
.url("$baseUrl/banner/consent")
|
|
.post(body.toRequestBody("application/json".toMediaType()))
|
|
.build()
|
|
|
|
client.newCall(request).execute().use { response ->
|
|
val responseBody = response.body?.string() ?: throw Exception("Empty response")
|
|
json.decodeFromString<ConsentResponse>(responseBody)
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Device Fingerprint
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Privacy-konformer Device Fingerprint
|
|
*/
|
|
internal object DeviceFingerprint {
|
|
fun generate(context: Context): String {
|
|
// Android ID (reset bei Factory Reset)
|
|
val androidId = Settings.Secure.getString(
|
|
context.contentResolver,
|
|
Settings.Secure.ANDROID_ID
|
|
) ?: UUID.randomUUID().toString()
|
|
|
|
// Device Info
|
|
val model = Build.MODEL
|
|
val version = Build.VERSION.SDK_INT.toString()
|
|
val locale = Locale.getDefault().toString()
|
|
|
|
// Hash erstellen
|
|
val raw = "$androidId-$model-$version-$locale"
|
|
val md = MessageDigest.getInstance("SHA-256")
|
|
val digest = md.digest(raw.toByteArray())
|
|
return digest.joinToString("") { "%02x".format(it) }
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Jetpack Compose Integration
|
|
// =============================================================================
|
|
|
|
/**
|
|
* State Holder fuer Compose
|
|
*/
|
|
@Composable
|
|
fun rememberConsentState(): State<ConsentState> {
|
|
return ConsentManager.current.consent.collectAsState()
|
|
}
|
|
|
|
/**
|
|
* Banner Visibility State
|
|
*/
|
|
@Composable
|
|
fun rememberBannerVisibility(): State<Boolean> {
|
|
return ConsentManager.current.isBannerVisible.collectAsState()
|
|
}
|
|
|
|
/**
|
|
* Consent Gate - Zeigt Inhalt nur bei Consent
|
|
*/
|
|
@Composable
|
|
fun ConsentGate(
|
|
category: ConsentCategory,
|
|
placeholder: @Composable () -> Unit = {},
|
|
content: @Composable () -> Unit
|
|
) {
|
|
val consent by rememberConsentState()
|
|
|
|
if (ConsentManager.current.hasConsent(category)) {
|
|
content()
|
|
} else {
|
|
placeholder()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Local Composition fuer ConsentManager
|
|
*/
|
|
val LocalConsentManager = staticCompositionLocalOf { ConsentManager.current }
|
|
|
|
/**
|
|
* Consent Provider
|
|
*/
|
|
@Composable
|
|
fun ConsentProvider(
|
|
content: @Composable () -> Unit
|
|
) {
|
|
CompositionLocalProvider(
|
|
LocalConsentManager provides ConsentManager.current
|
|
) {
|
|
content()
|
|
}
|
|
}
|