/** * 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 = defaultCategories(), val vendors: Map = 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 = _consent.asStateFlow() private val _isInitialized = MutableStateFlow(false) val isInitialized: StateFlow = _isInitialized.asStateFlow() private val _isLoading = MutableStateFlow(true) val isLoading: StateFlow = _isLoading.asStateFlow() private val _isBannerVisible = MutableStateFlow(false) val isBannerVisible: StateFlow = _isBannerVisible.asStateFlow() private val _isSettingsVisible = MutableStateFlow(false) val isSettingsVisible: StateFlow = _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) { 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(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(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(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 { return ConsentManager.current.consent.collectAsState() } /** * Banner Visibility State */ @Composable fun rememberBannerVisibility(): State { 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() } }