Initial commit: breakpilot-compliance - Compliance SDK Platform
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>
This commit is contained in:
499
consent-sdk/src/mobile/android/ConsentManager.kt
Normal file
499
consent-sdk/src/mobile/android/ConsentManager.kt
Normal file
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user