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:
182
consent-sdk/src/mobile/README.md
Normal file
182
consent-sdk/src/mobile/README.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Mobile SDKs - @breakpilot/consent-sdk
|
||||
|
||||
## Übersicht
|
||||
|
||||
Die Mobile SDKs bieten native Integration für iOS, Android und Flutter.
|
||||
|
||||
## SDK Übersicht
|
||||
|
||||
| Platform | Sprache | Min Version | Status |
|
||||
|----------|---------|-------------|--------|
|
||||
| iOS | Swift 5.9+ | iOS 15.0+ | 📋 Spec |
|
||||
| Android | Kotlin | API 26+ | 📋 Spec |
|
||||
| Flutter | Dart 3.0+ | Flutter 3.16+ | 📋 Spec |
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
Mobile SDK
|
||||
├── Core (shared)
|
||||
│ ├── ConsentManager
|
||||
│ ├── ConsentStorage (Keychain/SharedPrefs)
|
||||
│ ├── API Client
|
||||
│ └── Device Fingerprint
|
||||
├── UI Components
|
||||
│ ├── ConsentBanner
|
||||
│ ├── ConsentSettings
|
||||
│ └── ConsentGate
|
||||
└── Platform-specific
|
||||
├── iOS: SwiftUI + UIKit
|
||||
├── Android: Jetpack Compose + XML
|
||||
└── Flutter: Widgets
|
||||
```
|
||||
|
||||
## Feature-Parität mit Web SDK
|
||||
|
||||
| Feature | iOS | Android | Flutter |
|
||||
|---------|-----|---------|---------|
|
||||
| Consent Storage | Keychain | SharedPrefs | SecureStorage |
|
||||
| Banner UI | SwiftUI | Compose | Widget |
|
||||
| Settings Modal | ✓ | ✓ | ✓ |
|
||||
| Category Control | ✓ | ✓ | ✓ |
|
||||
| Vendor Control | ✓ | ✓ | ✓ |
|
||||
| Offline Support | ✓ | ✓ | ✓ |
|
||||
| Google Consent Mode | ✓ | ✓ | ✓ |
|
||||
| ATT Integration | ✓ | - | ✓ (iOS) |
|
||||
| TCF 2.2 | ✓ | ✓ | ✓ |
|
||||
|
||||
## Installation
|
||||
|
||||
### iOS (Swift Package Manager)
|
||||
|
||||
```swift
|
||||
// Package.swift
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/breakpilot/consent-sdk-ios.git", from: "1.0.0")
|
||||
]
|
||||
```
|
||||
|
||||
### Android (Gradle)
|
||||
|
||||
```kotlin
|
||||
// build.gradle.kts
|
||||
dependencies {
|
||||
implementation("com.breakpilot:consent-sdk:1.0.0")
|
||||
}
|
||||
```
|
||||
|
||||
### Flutter
|
||||
|
||||
```yaml
|
||||
# pubspec.yaml
|
||||
dependencies:
|
||||
breakpilot_consent_sdk: ^1.0.0
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### iOS
|
||||
|
||||
```swift
|
||||
import BreakpilotConsentSDK
|
||||
|
||||
// AppDelegate.swift
|
||||
ConsentManager.shared.configure(
|
||||
apiEndpoint: "https://consent.example.com/api/v1",
|
||||
siteId: "site_abc123"
|
||||
)
|
||||
|
||||
// ContentView.swift (SwiftUI)
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var consent: ConsentManager
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if consent.hasConsent(.analytics) {
|
||||
AnalyticsView()
|
||||
}
|
||||
}
|
||||
.consentBanner()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
```kotlin
|
||||
import com.breakpilot.consent.ConsentManager
|
||||
import com.breakpilot.consent.ui.ConsentBanner
|
||||
|
||||
// Application.kt
|
||||
class MyApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ConsentManager.configure(
|
||||
context = this,
|
||||
apiEndpoint = "https://consent.example.com/api/v1",
|
||||
siteId = "site_abc123"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MainActivity.kt (Jetpack Compose)
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
val consent = ConsentManager.current
|
||||
|
||||
if (consent.hasConsent(ConsentCategory.ANALYTICS)) {
|
||||
AnalyticsComponent()
|
||||
}
|
||||
|
||||
ConsentBanner()
|
||||
}
|
||||
```
|
||||
|
||||
### Flutter
|
||||
|
||||
```dart
|
||||
import 'package:breakpilot_consent_sdk/consent_sdk.dart';
|
||||
|
||||
// main.dart
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await ConsentManager.configure(
|
||||
apiEndpoint: 'https://consent.example.com/api/v1',
|
||||
siteId: 'site_abc123',
|
||||
);
|
||||
|
||||
runApp(MyApp());
|
||||
}
|
||||
|
||||
// Widget
|
||||
class MyApp extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConsentProvider(
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
ConsentGate(
|
||||
category: ConsentCategory.analytics,
|
||||
child: AnalyticsWidget(),
|
||||
placeholder: Text('Analytics nicht aktiviert'),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomSheet: ConsentBanner(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dateien
|
||||
|
||||
Siehe die einzelnen Platform-SDKs:
|
||||
|
||||
- [iOS SDK Spec](./ios/README.md)
|
||||
- [Android SDK Spec](./android/README.md)
|
||||
- [Flutter SDK Spec](./flutter/README.md)
|
||||
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()
|
||||
}
|
||||
}
|
||||
658
consent-sdk/src/mobile/flutter/consent_sdk.dart
Normal file
658
consent-sdk/src/mobile/flutter/consent_sdk.dart
Normal file
@@ -0,0 +1,658 @@
|
||||
/// Flutter Consent SDK
|
||||
///
|
||||
/// DSGVO/TTDSG-konformes Consent Management fuer Flutter Apps.
|
||||
///
|
||||
/// Nutzung:
|
||||
/// 1. In main() mit ConsentManager.configure() initialisieren
|
||||
/// 2. App mit ConsentProvider wrappen
|
||||
/// 3. Mit ConsentGate Inhalte schuetzen
|
||||
///
|
||||
/// Copyright (c) 2025 BreakPilot
|
||||
/// Apache License 2.0
|
||||
|
||||
library consent_sdk;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
// =============================================================================
|
||||
// Consent Categories
|
||||
// =============================================================================
|
||||
|
||||
/// Standard-Consent-Kategorien nach IAB TCF 2.2
|
||||
enum ConsentCategory {
|
||||
essential, // Technisch notwendig
|
||||
functional, // Personalisierung
|
||||
analytics, // Nutzungsanalyse
|
||||
marketing, // Werbung
|
||||
social, // Social Media
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Consent State
|
||||
// =============================================================================
|
||||
|
||||
/// Aktueller Consent-Zustand
|
||||
class ConsentState {
|
||||
final Map<ConsentCategory, bool> categories;
|
||||
final Map<String, bool> vendors;
|
||||
final DateTime timestamp;
|
||||
final String version;
|
||||
final String? consentId;
|
||||
final DateTime? expiresAt;
|
||||
final String? tcfString;
|
||||
|
||||
const ConsentState({
|
||||
required this.categories,
|
||||
this.vendors = const {},
|
||||
required this.timestamp,
|
||||
this.version = '1.0.0',
|
||||
this.consentId,
|
||||
this.expiresAt,
|
||||
this.tcfString,
|
||||
});
|
||||
|
||||
/// Default State mit nur essential = true
|
||||
factory ConsentState.defaultState() {
|
||||
return ConsentState(
|
||||
categories: {
|
||||
ConsentCategory.essential: true,
|
||||
ConsentCategory.functional: false,
|
||||
ConsentCategory.analytics: false,
|
||||
ConsentCategory.marketing: false,
|
||||
ConsentCategory.social: false,
|
||||
},
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
ConsentState copyWith({
|
||||
Map<ConsentCategory, bool>? categories,
|
||||
Map<String, bool>? vendors,
|
||||
DateTime? timestamp,
|
||||
String? version,
|
||||
String? consentId,
|
||||
DateTime? expiresAt,
|
||||
String? tcfString,
|
||||
}) {
|
||||
return ConsentState(
|
||||
categories: categories ?? this.categories,
|
||||
vendors: vendors ?? this.vendors,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
version: version ?? this.version,
|
||||
consentId: consentId ?? this.consentId,
|
||||
expiresAt: expiresAt ?? this.expiresAt,
|
||||
tcfString: tcfString ?? this.tcfString,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'categories': categories.map((k, v) => MapEntry(k.name, v)),
|
||||
'vendors': vendors,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'version': version,
|
||||
'consentId': consentId,
|
||||
'expiresAt': expiresAt?.toIso8601String(),
|
||||
'tcfString': tcfString,
|
||||
};
|
||||
|
||||
factory ConsentState.fromJson(Map<String, dynamic> json) {
|
||||
return ConsentState(
|
||||
categories: (json['categories'] as Map<String, dynamic>).map(
|
||||
(k, v) => MapEntry(
|
||||
ConsentCategory.values.firstWhere((e) => e.name == k),
|
||||
v as bool,
|
||||
),
|
||||
),
|
||||
vendors: Map<String, bool>.from(json['vendors'] ?? {}),
|
||||
timestamp: DateTime.parse(json['timestamp']),
|
||||
version: json['version'] ?? '1.0.0',
|
||||
consentId: json['consentId'],
|
||||
expiresAt: json['expiresAt'] != null
|
||||
? DateTime.parse(json['expiresAt'])
|
||||
: null,
|
||||
tcfString: json['tcfString'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Configuration
|
||||
// =============================================================================
|
||||
|
||||
/// SDK-Konfiguration
|
||||
class ConsentConfig {
|
||||
final String apiEndpoint;
|
||||
final String siteId;
|
||||
final String language;
|
||||
final bool showRejectAll;
|
||||
final bool showAcceptAll;
|
||||
final bool granularControl;
|
||||
final int rememberDays;
|
||||
final bool debug;
|
||||
|
||||
const ConsentConfig({
|
||||
required this.apiEndpoint,
|
||||
required this.siteId,
|
||||
this.language = 'en',
|
||||
this.showRejectAll = true,
|
||||
this.showAcceptAll = true,
|
||||
this.granularControl = true,
|
||||
this.rememberDays = 365,
|
||||
this.debug = false,
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Consent Manager
|
||||
// =============================================================================
|
||||
|
||||
/// Haupt-Manager fuer Consent-Verwaltung
|
||||
class ConsentManager extends ChangeNotifier {
|
||||
// Singleton
|
||||
static ConsentManager? _instance;
|
||||
static ConsentManager get instance => _instance!;
|
||||
|
||||
// State
|
||||
ConsentState _consent = ConsentState.defaultState();
|
||||
bool _isInitialized = false;
|
||||
bool _isLoading = true;
|
||||
bool _isBannerVisible = false;
|
||||
bool _isSettingsVisible = false;
|
||||
|
||||
// Private
|
||||
ConsentConfig? _config;
|
||||
late ConsentStorage _storage;
|
||||
late ConsentApiClient _apiClient;
|
||||
|
||||
// Getters
|
||||
ConsentState get consent => _consent;
|
||||
bool get isInitialized => _isInitialized;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isBannerVisible => _isBannerVisible;
|
||||
bool get isSettingsVisible => _isSettingsVisible;
|
||||
bool get needsConsent => _consent.consentId == null;
|
||||
|
||||
// Private constructor
|
||||
ConsentManager._();
|
||||
|
||||
/// Konfiguriert den ConsentManager
|
||||
/// Sollte in main() vor runApp() aufgerufen werden
|
||||
static Future<void> configure(ConsentConfig config) async {
|
||||
_instance = ConsentManager._();
|
||||
_instance!._config = config;
|
||||
_instance!._storage = ConsentStorage();
|
||||
_instance!._apiClient = ConsentApiClient(
|
||||
baseUrl: config.apiEndpoint,
|
||||
siteId: config.siteId,
|
||||
);
|
||||
|
||||
if (config.debug) {
|
||||
debugPrint('[ConsentSDK] Configured with siteId: ${config.siteId}');
|
||||
}
|
||||
|
||||
await _instance!._initialize();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Initialization
|
||||
// ==========================================================================
|
||||
|
||||
Future<void> _initialize() async {
|
||||
try {
|
||||
// Lokalen Consent laden
|
||||
final stored = await _storage.load();
|
||||
if (stored != null) {
|
||||
// Pruefen ob abgelaufen
|
||||
if (stored.expiresAt != null &&
|
||||
DateTime.now().isAfter(stored.expiresAt!)) {
|
||||
_consent = ConsentState.defaultState();
|
||||
await _storage.clear();
|
||||
} else {
|
||||
_consent = stored;
|
||||
}
|
||||
}
|
||||
|
||||
// Vom Server synchronisieren
|
||||
try {
|
||||
final fingerprint = await DeviceFingerprint.generate();
|
||||
final serverConsent = await _apiClient.getConsent(fingerprint);
|
||||
if (serverConsent != null) {
|
||||
_consent = serverConsent;
|
||||
await _storage.save(_consent);
|
||||
}
|
||||
} catch (e) {
|
||||
if (_config?.debug == true) {
|
||||
debugPrint('[ConsentSDK] Failed to sync consent: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
|
||||
// Banner anzeigen falls noetig
|
||||
if (needsConsent) {
|
||||
showBanner();
|
||||
}
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Public API
|
||||
// ==========================================================================
|
||||
|
||||
/// Prueft ob Consent fuer Kategorie erteilt wurde
|
||||
bool hasConsent(ConsentCategory category) {
|
||||
// Essential ist immer erlaubt
|
||||
if (category == ConsentCategory.essential) return true;
|
||||
return _consent.categories[category] ?? false;
|
||||
}
|
||||
|
||||
/// Alle Kategorien akzeptieren
|
||||
Future<void> acceptAll() async {
|
||||
final newCategories = {
|
||||
for (var cat in ConsentCategory.values) cat: true
|
||||
};
|
||||
final newConsent = _consent.copyWith(
|
||||
categories: newCategories,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
await _saveConsent(newConsent);
|
||||
hideBanner();
|
||||
}
|
||||
|
||||
/// Alle nicht-essentiellen Kategorien ablehnen
|
||||
Future<void> rejectAll() async {
|
||||
final newCategories = {
|
||||
for (var cat in ConsentCategory.values)
|
||||
cat: cat == ConsentCategory.essential
|
||||
};
|
||||
final newConsent = _consent.copyWith(
|
||||
categories: newCategories,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
await _saveConsent(newConsent);
|
||||
hideBanner();
|
||||
}
|
||||
|
||||
/// Auswahl speichern
|
||||
Future<void> saveSelection(Map<ConsentCategory, bool> categories) async {
|
||||
final updated = Map<ConsentCategory, bool>.from(categories);
|
||||
updated[ConsentCategory.essential] = true; // Essential immer true
|
||||
final newConsent = _consent.copyWith(
|
||||
categories: updated,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
await _saveConsent(newConsent);
|
||||
hideBanner();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// UI Control
|
||||
// ==========================================================================
|
||||
|
||||
void showBanner() {
|
||||
_isBannerVisible = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void hideBanner() {
|
||||
_isBannerVisible = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void showSettings() {
|
||||
_isSettingsVisible = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void hideSettings() {
|
||||
_isSettingsVisible = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Private Methods
|
||||
// ==========================================================================
|
||||
|
||||
Future<void> _saveConsent(ConsentState newConsent) async {
|
||||
// Lokal speichern
|
||||
await _storage.save(newConsent);
|
||||
|
||||
// An Server senden
|
||||
try {
|
||||
final fingerprint = await DeviceFingerprint.generate();
|
||||
final response = await _apiClient.saveConsent(newConsent, fingerprint);
|
||||
final updated = newConsent.copyWith(
|
||||
consentId: response['consentId'],
|
||||
expiresAt: DateTime.parse(response['expiresAt']),
|
||||
);
|
||||
_consent = updated;
|
||||
await _storage.save(updated);
|
||||
} catch (e) {
|
||||
// Lokal speichern auch bei Fehler
|
||||
_consent = newConsent;
|
||||
if (_config?.debug == true) {
|
||||
debugPrint('[ConsentSDK] Failed to sync consent: $e');
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Storage
|
||||
// =============================================================================
|
||||
|
||||
/// Sichere Speicherung mit flutter_secure_storage
|
||||
class ConsentStorage {
|
||||
final _storage = const FlutterSecureStorage();
|
||||
static const _key = 'breakpilot_consent_state';
|
||||
|
||||
Future<ConsentState?> load() async {
|
||||
final data = await _storage.read(key: _key);
|
||||
if (data == null) return null;
|
||||
try {
|
||||
return ConsentState.fromJson(jsonDecode(data));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> save(ConsentState consent) async {
|
||||
final data = jsonEncode(consent.toJson());
|
||||
await _storage.write(key: _key, value: data);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await _storage.delete(key: _key);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Client
|
||||
// =============================================================================
|
||||
|
||||
/// API Client fuer Backend-Kommunikation
|
||||
class ConsentApiClient {
|
||||
final String baseUrl;
|
||||
final String siteId;
|
||||
|
||||
ConsentApiClient({
|
||||
required this.baseUrl,
|
||||
required this.siteId,
|
||||
});
|
||||
|
||||
Future<ConsentState?> getConsent(String fingerprint) async {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/banner/consent?site_id=$siteId&fingerprint=$fingerprint'),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) return null;
|
||||
return ConsentState.fromJson(jsonDecode(response.body));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> saveConsent(
|
||||
ConsentState consent,
|
||||
String fingerprint,
|
||||
) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/banner/consent'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'site_id': siteId,
|
||||
'device_fingerprint': fingerprint,
|
||||
'categories': consent.categories.map((k, v) => MapEntry(k.name, v)),
|
||||
'vendors': consent.vendors,
|
||||
'platform': Platform.isIOS ? 'ios' : 'android',
|
||||
'app_version': '1.0.0', // TODO: Get from package_info_plus
|
||||
}),
|
||||
);
|
||||
|
||||
return jsonDecode(response.body);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Device Fingerprint
|
||||
// =============================================================================
|
||||
|
||||
/// Privacy-konformer Device Fingerprint
|
||||
class DeviceFingerprint {
|
||||
static Future<String> generate() async {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
String rawId;
|
||||
|
||||
if (Platform.isIOS) {
|
||||
final iosInfo = await deviceInfo.iosInfo;
|
||||
rawId = '${iosInfo.identifierForVendor}-${iosInfo.model}-${iosInfo.systemVersion}';
|
||||
} else if (Platform.isAndroid) {
|
||||
final androidInfo = await deviceInfo.androidInfo;
|
||||
rawId = '${androidInfo.id}-${androidInfo.model}-${androidInfo.version.sdkInt}';
|
||||
} else {
|
||||
rawId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
}
|
||||
|
||||
// SHA-256 Hash
|
||||
final bytes = utf8.encode(rawId);
|
||||
final digest = sha256.convert(bytes);
|
||||
return digest.toString();
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Flutter Widgets
|
||||
// =============================================================================
|
||||
|
||||
/// Consent Provider - Wraps the app
|
||||
class ConsentProvider extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const ConsentProvider({
|
||||
super.key,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: ConsentManager.instance,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Consent Gate - Zeigt Inhalt nur bei Consent
|
||||
class ConsentGate extends StatelessWidget {
|
||||
final ConsentCategory category;
|
||||
final Widget child;
|
||||
final Widget? placeholder;
|
||||
final Widget? loading;
|
||||
|
||||
const ConsentGate({
|
||||
super.key,
|
||||
required this.category,
|
||||
required this.child,
|
||||
this.placeholder,
|
||||
this.loading,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<ConsentManager>(
|
||||
builder: (context, consent, _) {
|
||||
if (consent.isLoading) {
|
||||
return loading ?? const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (!consent.hasConsent(category)) {
|
||||
return placeholder ?? const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return child;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Consent Banner - Default Banner UI
|
||||
class ConsentBanner extends StatelessWidget {
|
||||
final String? title;
|
||||
final String? description;
|
||||
final String? acceptAllText;
|
||||
final String? rejectAllText;
|
||||
final String? settingsText;
|
||||
|
||||
const ConsentBanner({
|
||||
super.key,
|
||||
this.title,
|
||||
this.description,
|
||||
this.acceptAllText,
|
||||
this.rejectAllText,
|
||||
this.settingsText,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<ConsentManager>(
|
||||
builder: (context, consent, _) {
|
||||
if (!consent.isBannerVisible) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, -5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title ?? 'Datenschutzeinstellungen',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
description ??
|
||||
'Wir nutzen Cookies und ähnliche Technologien, um Ihnen ein optimales Nutzererlebnis zu bieten.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: consent.rejectAll,
|
||||
child: Text(rejectAllText ?? 'Alle ablehnen'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: consent.showSettings,
|
||||
child: Text(settingsText ?? 'Einstellungen'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: consent.acceptAll,
|
||||
child: Text(acceptAllText ?? 'Alle akzeptieren'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Consent Placeholder - Placeholder fuer blockierten Inhalt
|
||||
class ConsentPlaceholder extends StatelessWidget {
|
||||
final ConsentCategory category;
|
||||
final String? message;
|
||||
final String? buttonText;
|
||||
|
||||
const ConsentPlaceholder({
|
||||
super.key,
|
||||
required this.category,
|
||||
this.message,
|
||||
this.buttonText,
|
||||
});
|
||||
|
||||
String get _categoryName {
|
||||
switch (category) {
|
||||
case ConsentCategory.essential:
|
||||
return 'Essentielle Cookies';
|
||||
case ConsentCategory.functional:
|
||||
return 'Funktionale Cookies';
|
||||
case ConsentCategory.analytics:
|
||||
return 'Statistik-Cookies';
|
||||
case ConsentCategory.marketing:
|
||||
return 'Marketing-Cookies';
|
||||
case ConsentCategory.social:
|
||||
return 'Social Media-Cookies';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
message ?? 'Dieser Inhalt erfordert $_categoryName.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: ConsentManager.instance.showSettings,
|
||||
child: Text(buttonText ?? 'Cookie-Einstellungen öffnen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Extension for easy context access
|
||||
// =============================================================================
|
||||
|
||||
extension ConsentExtension on BuildContext {
|
||||
ConsentManager get consent => Provider.of<ConsentManager>(this, listen: false);
|
||||
|
||||
bool hasConsent(ConsentCategory category) => consent.hasConsent(category);
|
||||
}
|
||||
517
consent-sdk/src/mobile/ios/ConsentManager.swift
Normal file
517
consent-sdk/src/mobile/ios/ConsentManager.swift
Normal file
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* iOS Consent SDK - ConsentManager
|
||||
*
|
||||
* DSGVO/TTDSG-konformes Consent Management fuer iOS Apps.
|
||||
*
|
||||
* Nutzung:
|
||||
* 1. Im AppDelegate/App.init() konfigurieren
|
||||
* 2. In SwiftUI Views mit @EnvironmentObject nutzen
|
||||
* 3. Banner mit .consentBanner() Modifier anzeigen
|
||||
*
|
||||
* Copyright (c) 2025 BreakPilot
|
||||
* Apache License 2.0
|
||||
*/
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Consent Categories
|
||||
|
||||
/// Standard-Consent-Kategorien nach IAB TCF 2.2
|
||||
public enum ConsentCategory: String, CaseIterable, Codable {
|
||||
case essential // Technisch notwendig
|
||||
case functional // Personalisierung
|
||||
case analytics // Nutzungsanalyse
|
||||
case marketing // Werbung
|
||||
case social // Social Media
|
||||
}
|
||||
|
||||
// MARK: - Consent State
|
||||
|
||||
/// Aktueller Consent-Zustand
|
||||
public struct ConsentState: Codable, Equatable {
|
||||
public var categories: [ConsentCategory: Bool]
|
||||
public var vendors: [String: Bool]
|
||||
public var timestamp: Date
|
||||
public var version: String
|
||||
public var consentId: String?
|
||||
public var expiresAt: Date?
|
||||
public var tcfString: String?
|
||||
|
||||
public init(
|
||||
categories: [ConsentCategory: Bool] = [:],
|
||||
vendors: [String: Bool] = [:],
|
||||
timestamp: Date = Date(),
|
||||
version: String = "1.0.0"
|
||||
) {
|
||||
self.categories = categories
|
||||
self.vendors = vendors
|
||||
self.timestamp = timestamp
|
||||
self.version = version
|
||||
}
|
||||
|
||||
/// Default State mit nur essential = true
|
||||
public static var `default`: ConsentState {
|
||||
ConsentState(
|
||||
categories: [
|
||||
.essential: true,
|
||||
.functional: false,
|
||||
.analytics: false,
|
||||
.marketing: false,
|
||||
.social: false
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// SDK-Konfiguration
|
||||
public struct ConsentConfig {
|
||||
public let apiEndpoint: String
|
||||
public let siteId: String
|
||||
public var language: String = Locale.current.language.languageCode?.identifier ?? "en"
|
||||
public var showRejectAll: Bool = true
|
||||
public var showAcceptAll: Bool = true
|
||||
public var granularControl: Bool = true
|
||||
public var rememberDays: Int = 365
|
||||
public var debug: Bool = false
|
||||
|
||||
public init(apiEndpoint: String, siteId: String) {
|
||||
self.apiEndpoint = apiEndpoint
|
||||
self.siteId = siteId
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Consent Manager
|
||||
|
||||
/// Haupt-Manager fuer Consent-Verwaltung
|
||||
@MainActor
|
||||
public final class ConsentManager: ObservableObject {
|
||||
|
||||
// MARK: Singleton
|
||||
|
||||
public static let shared = ConsentManager()
|
||||
|
||||
// MARK: Published Properties
|
||||
|
||||
@Published public private(set) var consent: ConsentState = .default
|
||||
@Published public private(set) var isInitialized: Bool = false
|
||||
@Published public private(set) var isLoading: Bool = true
|
||||
@Published public private(set) var isBannerVisible: Bool = false
|
||||
@Published public private(set) var isSettingsVisible: Bool = false
|
||||
|
||||
// MARK: Private Properties
|
||||
|
||||
private var config: ConsentConfig?
|
||||
private var storage: ConsentStorage?
|
||||
private var apiClient: ConsentAPIClient?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Konfiguriert den ConsentManager
|
||||
public func configure(_ config: ConsentConfig) {
|
||||
self.config = config
|
||||
self.storage = ConsentStorage()
|
||||
self.apiClient = ConsentAPIClient(
|
||||
baseURL: config.apiEndpoint,
|
||||
siteId: config.siteId
|
||||
)
|
||||
|
||||
if config.debug {
|
||||
print("[ConsentSDK] Configured with siteId: \(config.siteId)")
|
||||
}
|
||||
|
||||
Task {
|
||||
await initialize()
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialisiert und laedt gespeicherten Consent
|
||||
private func initialize() async {
|
||||
defer { isLoading = false }
|
||||
|
||||
// Lokalen Consent laden
|
||||
if let stored = storage?.load() {
|
||||
consent = stored
|
||||
|
||||
// Pruefen ob abgelaufen
|
||||
if let expiresAt = stored.expiresAt, Date() > expiresAt {
|
||||
consent = .default
|
||||
storage?.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Vom Server synchronisieren (optional)
|
||||
do {
|
||||
if let serverConsent = try await apiClient?.getConsent(
|
||||
fingerprint: DeviceFingerprint.generate()
|
||||
) {
|
||||
consent = serverConsent
|
||||
storage?.save(consent)
|
||||
}
|
||||
} catch {
|
||||
if config?.debug == true {
|
||||
print("[ConsentSDK] Failed to sync consent: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
isInitialized = true
|
||||
|
||||
// Banner anzeigen falls noetig
|
||||
if needsConsent {
|
||||
showBanner()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Prueft ob Consent fuer Kategorie erteilt wurde
|
||||
public func hasConsent(_ category: ConsentCategory) -> Bool {
|
||||
// Essential ist immer erlaubt
|
||||
if category == .essential { return true }
|
||||
return consent.categories[category] ?? false
|
||||
}
|
||||
|
||||
/// Prueft ob Consent eingeholt werden muss
|
||||
public var needsConsent: Bool {
|
||||
consent.consentId == nil
|
||||
}
|
||||
|
||||
/// Alle Kategorien akzeptieren
|
||||
public func acceptAll() async {
|
||||
var newConsent = consent
|
||||
for category in ConsentCategory.allCases {
|
||||
newConsent.categories[category] = true
|
||||
}
|
||||
newConsent.timestamp = Date()
|
||||
|
||||
await saveConsent(newConsent)
|
||||
hideBanner()
|
||||
}
|
||||
|
||||
/// Alle nicht-essentiellen Kategorien ablehnen
|
||||
public func rejectAll() async {
|
||||
var newConsent = consent
|
||||
for category in ConsentCategory.allCases {
|
||||
newConsent.categories[category] = category == .essential
|
||||
}
|
||||
newConsent.timestamp = Date()
|
||||
|
||||
await saveConsent(newConsent)
|
||||
hideBanner()
|
||||
}
|
||||
|
||||
/// Auswahl speichern
|
||||
public func saveSelection(_ categories: [ConsentCategory: Bool]) async {
|
||||
var newConsent = consent
|
||||
newConsent.categories = categories
|
||||
newConsent.categories[.essential] = true // Essential immer true
|
||||
newConsent.timestamp = Date()
|
||||
|
||||
await saveConsent(newConsent)
|
||||
hideBanner()
|
||||
}
|
||||
|
||||
// MARK: - UI Control
|
||||
|
||||
/// Banner anzeigen
|
||||
public func showBanner() {
|
||||
isBannerVisible = true
|
||||
}
|
||||
|
||||
/// Banner ausblenden
|
||||
public func hideBanner() {
|
||||
isBannerVisible = false
|
||||
}
|
||||
|
||||
/// Einstellungen anzeigen
|
||||
public func showSettings() {
|
||||
isSettingsVisible = true
|
||||
}
|
||||
|
||||
/// Einstellungen ausblenden
|
||||
public func hideSettings() {
|
||||
isSettingsVisible = false
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func saveConsent(_ newConsent: ConsentState) async {
|
||||
// Lokal speichern
|
||||
storage?.save(newConsent)
|
||||
|
||||
// An Server senden
|
||||
do {
|
||||
let response = try await apiClient?.saveConsent(
|
||||
consent: newConsent,
|
||||
fingerprint: DeviceFingerprint.generate()
|
||||
)
|
||||
var updated = newConsent
|
||||
updated.consentId = response?.consentId
|
||||
updated.expiresAt = response?.expiresAt
|
||||
consent = updated
|
||||
storage?.save(updated)
|
||||
} catch {
|
||||
// Lokal speichern auch bei Fehler
|
||||
consent = newConsent
|
||||
if config?.debug == true {
|
||||
print("[ConsentSDK] Failed to sync consent: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Storage
|
||||
|
||||
/// Sichere Speicherung im Keychain
|
||||
final class ConsentStorage {
|
||||
private let key = "com.breakpilot.consent.state"
|
||||
|
||||
func load() -> ConsentState? {
|
||||
guard let data = KeychainHelper.read(key: key) else { return nil }
|
||||
return try? JSONDecoder().decode(ConsentState.self, from: data)
|
||||
}
|
||||
|
||||
func save(_ consent: ConsentState) {
|
||||
guard let data = try? JSONEncoder().encode(consent) else { return }
|
||||
KeychainHelper.write(data: data, key: key)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
KeychainHelper.delete(key: key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Keychain Helper
|
||||
enum KeychainHelper {
|
||||
static func write(data: Data, key: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
static func read(key: String) -> Data? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
var result: AnyObject?
|
||||
SecItemCopyMatching(query as CFDictionary, &result)
|
||||
return result as? Data
|
||||
}
|
||||
|
||||
static func delete(key: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API Client
|
||||
|
||||
/// API Client fuer Backend-Kommunikation
|
||||
final class ConsentAPIClient {
|
||||
private let baseURL: String
|
||||
private let siteId: String
|
||||
|
||||
init(baseURL: String, siteId: String) {
|
||||
self.baseURL = baseURL
|
||||
self.siteId = siteId
|
||||
}
|
||||
|
||||
struct ConsentResponse: Codable {
|
||||
let consentId: String
|
||||
let expiresAt: Date
|
||||
}
|
||||
|
||||
func getConsent(fingerprint: String) async throws -> ConsentState? {
|
||||
let url = URL(string: "\(baseURL)/banner/consent?site_id=\(siteId)&fingerprint=\(fingerprint)")!
|
||||
let (data, response) = try await URLSession.shared.data(from: url)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(ConsentState.self, from: data)
|
||||
}
|
||||
|
||||
func saveConsent(consent: ConsentState, fingerprint: String) async throws -> ConsentResponse {
|
||||
var request = URLRequest(url: URL(string: "\(baseURL)/banner/consent")!)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body: [String: Any] = [
|
||||
"site_id": siteId,
|
||||
"device_fingerprint": fingerprint,
|
||||
"categories": Dictionary(
|
||||
uniqueKeysWithValues: consent.categories.map { ($0.key.rawValue, $0.value) }
|
||||
),
|
||||
"vendors": consent.vendors,
|
||||
"platform": "ios",
|
||||
"app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
|
||||
]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
return try JSONDecoder().decode(ConsentResponse.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Device Fingerprint
|
||||
|
||||
/// Privacy-konformer Device Fingerprint
|
||||
enum DeviceFingerprint {
|
||||
static func generate() -> String {
|
||||
// Vendor ID (reset-safe)
|
||||
let vendorId = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
|
||||
|
||||
// System Info
|
||||
let model = UIDevice.current.model
|
||||
let systemVersion = UIDevice.current.systemVersion
|
||||
let locale = Locale.current.identifier
|
||||
|
||||
// Hash erstellen
|
||||
let raw = "\(vendorId)-\(model)-\(systemVersion)-\(locale)"
|
||||
let data = Data(raw.utf8)
|
||||
let hash = SHA256.hash(data: data)
|
||||
return hash.compactMap { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI Extensions
|
||||
|
||||
/// Environment Key fuer ConsentManager
|
||||
private struct ConsentManagerKey: EnvironmentKey {
|
||||
static let defaultValue = ConsentManager.shared
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
public var consentManager: ConsentManager {
|
||||
get { self[ConsentManagerKey.self] }
|
||||
set { self[ConsentManagerKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
/// Banner ViewModifier
|
||||
public struct ConsentBannerModifier: ViewModifier {
|
||||
@ObservedObject var consent = ConsentManager.shared
|
||||
|
||||
public func body(content: Content) -> some View {
|
||||
ZStack {
|
||||
content
|
||||
|
||||
if consent.isBannerVisible {
|
||||
ConsentBannerView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Fuegt einen Consent-Banner hinzu
|
||||
public func consentBanner() -> some View {
|
||||
modifier(ConsentBannerModifier())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Banner View
|
||||
|
||||
/// Default Consent Banner UI
|
||||
public struct ConsentBannerView: View {
|
||||
@ObservedObject var consent = ConsentManager.shared
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 16) {
|
||||
Text("Datenschutzeinstellungen")
|
||||
.font(.headline)
|
||||
|
||||
Text("Wir nutzen Cookies und ähnliche Technologien, um Ihnen ein optimales Nutzererlebnis zu bieten.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button("Alle ablehnen") {
|
||||
Task { await consent.rejectAll() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Einstellungen") {
|
||||
consent.showSettings()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Alle akzeptieren") {
|
||||
Task { await consent.acceptAll() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.padding()
|
||||
.shadow(radius: 20)
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.animation(.spring(), value: consent.isBannerVisible)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Consent Gate
|
||||
|
||||
/// Zeigt Inhalt nur bei Consent an
|
||||
public struct ConsentGate<Content: View, Placeholder: View>: View {
|
||||
let category: ConsentCategory
|
||||
let content: () -> Content
|
||||
let placeholder: () -> Placeholder
|
||||
|
||||
@ObservedObject var consent = ConsentManager.shared
|
||||
|
||||
public init(
|
||||
category: ConsentCategory,
|
||||
@ViewBuilder content: @escaping () -> Content,
|
||||
@ViewBuilder placeholder: @escaping () -> Placeholder
|
||||
) {
|
||||
self.category = category
|
||||
self.content = content
|
||||
self.placeholder = placeholder
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
if consent.hasConsent(category) {
|
||||
content()
|
||||
} else {
|
||||
placeholder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConsentGate where Placeholder == EmptyView {
|
||||
public init(
|
||||
category: ConsentCategory,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.init(category: category, content: content, placeholder: { EmptyView() })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user