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:
Benjamin Boenisch
2026-02-11 23:47:28 +01:00
commit 4435e7ea0a
734 changed files with 251369 additions and 0 deletions

View 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)

View 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()
}
}

View 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);
}

View 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() })
}
}