
Consent Flow
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates GDPR/CCPA/DPDP privacy consent flows with granular category preferences, consent persistence, audit logging, and App Tracking Transparency integration.
About
Generates a privacy consent system with a consent banner, granular category preferences, persistent state, audit logging, and ATT integration. A developer uses it to add tracking/privacy consent and compliance management to an iOS app.
- Granular category-based consent with persistent state and audit log
- Integrates App Tracking Transparency (ATT) prompts
Consent Flow by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #887 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill consent-flowAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates GDPR/CCPA/DPDP privacy consent flows with granular category preferences, consent persistence, audit logging, and App Tracking Transparency integration.
Files
Consent Flow Generator
Generate a production privacy consent system with granular category-based consent, persistent state management, a consent banner and preferences UI, audit logging for compliance, and App Tracking Transparency integration.
When This Skill Activates
Use this skill when the user:
- Asks about "privacy consent" or "consent management"
- Mentions "GDPR consent" or "GDPR compliance"
- Wants "cookie consent" or "tracking consent"
- Mentions "ATT prompt" or "App Tracking Transparency"
- Asks for "privacy preferences" or "consent preferences"
- Mentions "CCPA compliance" or "DPDP compliance"
- Wants to "manage user consent" or "consent banner"
- Asks about "consent audit log" or "consent records"
Pre-Generation Checks
1. Project Context Detection
- [ ] Check Swift version (requires Swift 5.9+)
- [ ] Check deployment target (iOS 16+ / macOS 13+)
- [ ] Check for @Observable support (iOS 17+ / macOS 14+)
- [ ] Identify source file locations
2. Conflict Detection
Search for existing consent or privacy code:
Glob: **/*Consent*.swift, **/*Privacy*.swift, **/*Tracking*.swift, **/*GDPR*.swift
Grep: "ATTrackingManager" or "ConsentManager" or "trackingAuthorizationStatus"If third-party library found (OneTrust, Usercentrics, CookieBot):
- Ask if user wants to replace or keep it
- If keeping, don't generate — advise on integration best practices instead
3. ATT Framework Availability
Check for App Tracking Transparency framework:
Grep: "AppTrackingTransparency" in project files
Grep: "NSUserTrackingUsageDescription" in Info.plistIf NSUserTrackingUsageDescription is missing from Info.plist, warn the user that ATT requires this key and offer to add it.
4. Platform Detection
Determine if generating for iOS (primary ATT target) or macOS (ATT not applicable) or both.
Configuration Questions
Ask user via AskUserQuestion:
1. Target regulations?
- GDPR only (EU — opt-in model)
- CCPA only (California — opt-out model)
- DPDP only (India — consent-based)
- All regulations (recommended for global apps)
2. Consent categories? (multi-select)
- Essential (always on, cannot be disabled)
- Analytics (usage tracking, crash reporting)
- Marketing (advertising, attribution)
- Personalization (recommendations, content tailoring)
- Functional (preferences, saved settings beyond essential)
3. Include ATT integration?
- Yes — request ATT permission before any tracking (recommended for iOS)
- No — handle consent without ATT (macOS, or no IDFA usage)
4. Consent UI style?
- Bottom banner with manage preferences (recommended)
- Full-screen consent view (for first launch)
- Settings-embedded (preferences in app settings, no banner)
- Banner + Settings (banner on first launch, preferences in settings)
Generation Process
Step 1: Read Templates
Read templates.md for production Swift code. Read patterns.md for compliance rules, regulation differences, and UX guidance.
Step 2: Create Core Files
Generate these files: 1. ConsentCategory.swift — Enum of consent categories with metadata 2. ConsentDecision.swift — Per-category consent state with timestamp 3. ConsentManager.swift — @Observable manager with persistence and ATT integration 4. ConsentAuditLog.swift — Compliance audit trail with JSON export
Step 3: Create UI Files
5. ConsentBannerView.swift — Animated slide-up consent banner 6. ConsentPreferencesView.swift — Detailed toggle list for each category
Step 4: Create Optional Files
Based on configuration:
ConsentRegulationConfig.swift— If multiple regulations selectedConsentATTBridge.swift— If ATT integration selected (extracted for testability)
Step 5: Determine File Location
Check project structure:
- If
Sources/exists →Sources/Consent/ - If
App/exists →App/Consent/ - Otherwise →
Consent/
Output Format
After generation, provide:
Files Created
Consent/
├── ConsentCategory.swift # Consent category enum with metadata
├── ConsentDecision.swift # Per-category decision with timestamp
├── ConsentManager.swift # @Observable manager with persistence
├── ConsentAuditLog.swift # Compliance audit trail
├── ConsentBannerView.swift # Animated consent banner
├── ConsentPreferencesView.swift # Granular preferences UI
├── ConsentRegulationConfig.swift # Multi-regulation rules (optional)
└── ConsentATTBridge.swift # ATT integration bridge (optional)Integration Steps
Show consent on first launch:
@main
struct MyApp: App {
@State private var consentManager = ConsentManager()
var body: some Scene {
WindowGroup {
ContentView()
.environment(consentManager)
.overlay(alignment: .bottom) {
if consentManager.needsConsent {
ConsentBannerView()
.environment(consentManager)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.3), value: consentManager.needsConsent)
}
}
}Check consent before tracking:
func trackEvent(_ event: AnalyticsEvent) {
guard consentManager.hasConsent(for: .analytics) else { return }
analyticsService.track(event)
}
func showPersonalizedAd() {
guard consentManager.hasConsent(for: .marketing) else {
showGenericAd()
return
}
adService.showPersonalized()
}Open preferences from settings:
NavigationLink("Privacy Preferences") {
ConsentPreferencesView()
.environment(consentManager)
}Export audit log for data requests:
func handleDataRequest() async throws -> Data {
let auditLog = ConsentAuditLog.shared
return try auditLog.exportJSON()
}Testing
@Test
func consentGrantedPersistsAcrossLaunches() async {
let defaults = UserDefaults(suiteName: "test")!
defaults.removePersistentDomain(forName: "test")
let manager = ConsentManager(defaults: defaults)
manager.updateConsent(for: .analytics, granted: true)
let manager2 = ConsentManager(defaults: defaults)
#expect(manager2.hasConsent(for: .analytics) == true)
}
@Test
func essentialConsentCannotBeRevoked() {
let manager = ConsentManager()
manager.updateConsent(for: .essential, granted: false)
#expect(manager.hasConsent(for: .essential) == true) // Always granted
}
@Test
func auditLogRecordsDecisions() {
let log = ConsentAuditLog(directory: tempDirectory)
log.record(category: .analytics, granted: true, regulation: .gdpr)
let entries = log.allEntries()
#expect(entries.count == 1)
#expect(entries[0].category == .analytics)
#expect(entries[0].granted == true)
}
@Test
func denyAllRevokesNonEssentialCategories() {
let manager = ConsentManager()
manager.grantAll()
manager.denyAllNonEssential()
#expect(manager.hasConsent(for: .essential) == true)
#expect(manager.hasConsent(for: .analytics) == false)
#expect(manager.hasConsent(for: .marketing) == false)
}Common Patterns
Show Consent on First Launch
.onAppear {
if consentManager.needsConsent {
// Banner auto-shows via overlay
}
}Update Preferences Later
Button("Privacy Settings") {
showPreferences = true
}
.sheet(isPresented: $showPreferences) {
ConsentPreferencesView()
.environment(consentManager)
}Check Consent Before Any Tracking Call
extension ConsentManager {
func executeIfConsented(
category: ConsentCategory,
action: () -> Void
) {
guard hasConsent(for: category) else { return }
action()
}
}Export Audit Log for Data Subject Requests
let jsonData = try consentManager.auditLog.exportJSON()
// Attach to email or upload to compliance endpointGotchas
ATT Must Be Requested Before Any Tracking
Apple rejects apps that access IDFA before calling ATTrackingManager.requestTrackingAuthorization. Always request ATT first, then enable tracking SDKs based on the result.
GDPR Requires Opt-In (Not Opt-Out)
Under GDPR, all non-essential tracking requires explicit opt-in consent. Pre-checked boxes or implied consent are not valid. The default state for all non-essential categories must be .notDetermined, not .granted.
Consent Must Be As Easy to Withdraw As to Grant
GDPR Article 7(3): "It shall be as easy to withdraw as to give consent." If consent is granted with one tap on a banner, it must be revocable with equal ease — not buried 5 screens deep in settings.
Different Regulations Have Different Age Thresholds
- GDPR: 16 years (member states can lower to 13)
- CCPA: 16 years for sale of data, 13 for minors
- DPDP: 18 years (parental consent required below)
If your app serves minors, you need age verification before consent collection.
Don't Block the UI on ATT
ATTrackingManager.requestTrackingAuthorization is async and shows a system dialog. Never call it during app launch or in a way that blocks the main UI. Show your own consent banner first, then request ATT as a secondary step.
Consent State Must Survive App Reinstall (GDPR)
For GDPR compliance, consider syncing consent state to a server. UserDefaults is deleted on app uninstall. If a user reinstalls, you must re-request consent — never assume prior consent.
References
- templates.md — All production Swift templates for consent flow
- patterns.md — Regulation comparison, ATT details, UX best practices, anti-patterns
- Related:
generators/permission-priming— Pre-permission UI patterns (ATT priming) - Related:
generators/analytics-setup— Analytics that respects consent state - Related:
generators/settings-screen— Embedding consent preferences in settings
Consent Flow Patterns & Compliance Reference
Regulation Comparison
| Aspect | GDPR (EU) | CCPA (California) | DPDP (India) |
|---|---|---|---|
| Consent Model | Opt-in required | Opt-out (right to say no) | Opt-in required |
| Default State | Not determined | Implied consent until opt-out | Not determined |
| Age Threshold | 16 (states can lower to 13) | 16 (13 for sale-of-data) | 18 |
| Right to Access | Yes (30 days) | Yes (45 days) | Yes |
| Right to Delete | Yes | Yes | Yes |
| Data Portability | Yes (machine-readable) | No (not required) | Yes |
| Penalties | Up to 4% global revenue or 20M EUR | $2,500–$7,500 per violation | Up to 250 crore INR |
| Applies To | Any company processing EU resident data | Businesses meeting CA thresholds | Processing of Indian citizens' data |
| Consent Withdrawal | Must be as easy as giving consent | Must provide opt-out mechanism | Must be as easy as giving consent |
| Record Keeping | Must demonstrate consent was given | Must record opt-out requests | Must maintain records |
| Cross-Border | Requires adequacy decisions or SCCs | No specific restriction | Government may restrict transfers |
Practical Implementation Differences
/// Determine initial consent state based on applicable regulation.
func initialConsentStatus(
for category: ConsentCategory,
regulation: ConsentRegulation
) -> ConsentStatus {
guard !category.isRequired else { return .granted }
switch regulation {
case .gdpr:
return .notDetermined // Must explicitly opt in
case .ccpa:
return .granted // Opted in by default, user can opt out
case .dpdp:
return .notDetermined // Must explicitly opt in
}
}Apple's App Tracking Transparency (ATT)
What ATT Covers
ATT controls access to the device's IDFA (Identifier for Advertisers). You must request ATT before:
- Accessing
ASIdentifierManager.shared().advertisingIdentifier - Using any SDK that reads the IDFA (Facebook SDK, Google Ads, etc.)
- Fingerprinting the device for tracking across apps/websites
When to Request ATT
App Launch ──► Show Own Consent Banner ──► User Taps "Accept" ──► Request ATT ──► Enable Tracking
│
├── User Taps "Reject" ──► No ATT Request ──► No Tracking
│
└── User Taps "Manage" ──► Preferences ──► Based on ChoicesKey rules: 1. Never request ATT at cold launch — Apple may reject the app 2. Show your own explanation first, then call requestTrackingAuthorization 3. ATT dialog can only be shown once — subsequent calls return the cached status 4. If denied, you cannot re-prompt; guide users to Settings > Privacy > Tracking
ATT and Consent Relationship
/// Map ATT authorization status to marketing consent.
func syncATTWithConsent(
attStatus: ATTrackingManager.AuthorizationStatus,
consentManager: ConsentManager
) {
switch attStatus {
case .authorized:
// User allowed tracking at OS level — check app-level consent too
// Both ATT AND app consent must be granted
break
case .denied:
// User denied at OS level — must deny marketing regardless of app consent
consentManager.updateConsent(for: .marketing, granted: false)
case .restricted:
// Device management or parental controls — cannot track
consentManager.updateConsent(for: .marketing, granted: false)
case .notDetermined:
// Haven't asked yet
break
@unknown default:
break
}
}ATT and SKAdNetwork
SKAdNetwork provides privacy-preserving ad attribution without requiring ATT:
- Aggregated conversion data (not per-user)
- Delayed postbacks to prevent user identification
- No IDFA access needed
If ATT is denied, fall back to SKAdNetwork for attribution data.
Resetting ATT in Simulator for Testing
ATT authorization persists per app install. To reset: 1. Simulator: Delete the app and reinstall, OR reset the simulator (Device > Erase All Content and Settings) 2. Device: Settings > General > Transfer or Reset > Reset > Reset Location & Privacy
// Check current ATT status without prompting
let currentStatus = ATTrackingManager.trackingAuthorizationStatus
print("ATT Status: \(currentStatus.rawValue)")
// 0 = notDetermined, 1 = restricted, 2 = denied, 3 = authorizedConsent UX Best Practices
Progressive Disclosure
Show a brief banner first. Detailed preferences are one tap away:
┌─────────────────────────────────────────────┐
│ 🛡️ Your Privacy Matters │
│ │
│ We use data processing to improve your │
│ experience. You choose what's allowed. │
│ │
│ [ Accept All ] (prominent, but not only) │
│ [Manage] [Reject Non-Essential] │
│ │
│ Privacy Policy │
└─────────────────────────────────────────────┘No Dark Patterns
These practices violate GDPR and lead to App Store rejection:
| Dark Pattern | Problem | Correct Approach |
|---|---|---|
| Pre-checked consent boxes | Not valid consent under GDPR | All non-essential default to off |
| Consent wall (block app until accept) | Coerced consent is not freely given | Allow "Reject" and still use app |
| "Accept" is prominent, "Reject" is hidden | Unequal treatment of choices | Equal visual prominence for all options |
| "Accept" is one tap, "Reject" requires 5 steps | Withdrawal must be as easy as granting | Same number of taps for both |
| Misleading category names | Must be clear and specific | Plain language descriptions |
| Auto-dismissing banner | User didn't make a choice | Banner persists until action taken |
| Re-prompting after rejection | Nagging violates GDPR | Respect the decision, offer settings link |
Equal Prominence for Accept/Reject
// ❌ Wrong — "Accept" is prominent, "Reject" is a text link
Button("Accept All") { acceptAll() }
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button("Reject") { rejectAll() }
.font(.caption)
.foregroundStyle(.secondary)
// ✅ Right — Both actions have clear, tappable buttons
Button("Accept All") { acceptAll() }
.buttonStyle(.borderedProminent)
.controlSize(.large)
HStack(spacing: 10) {
Button("Manage Preferences") { showPreferences() }
.buttonStyle(.bordered)
.controlSize(.regular)
Button("Reject Non-Essential") { rejectAll() }
.buttonStyle(.bordered)
.controlSize(.regular)
}Consent Language
// ❌ Wrong — Vague, manipulative
"We need your data to give you the best experience. Please accept to continue."
// ✅ Right — Specific, neutral
"We use analytics to understand how the app is used and improve it.
Marketing data helps show relevant ads. You can change these choices anytime in Settings."Data Rights Implementation
Right to Access (GDPR Art. 15, CCPA, DPDP)
/// Compile all user data for a data subject access request.
struct DataAccessReport: Codable {
let generatedAt: Date
let consentHistory: [ConsentAuditLog.Entry]
let currentPreferences: [String: String]
let dataCategories: [DataCategoryReport]
struct DataCategoryReport: Codable {
let category: String
let description: String
let dataPoints: [String]
let retentionPeriod: String
}
}
func generateAccessReport(
consentManager: ConsentManager,
auditLog: ConsentAuditLog
) throws -> Data {
let report = DataAccessReport(
generatedAt: Date(),
consentHistory: auditLog.allEntries(),
currentPreferences: Dictionary(
uniqueKeysWithValues: ConsentCategory.allCases.map {
($0.displayName, consentManager.hasConsent(for: $0) ? "Granted" : "Denied")
}
),
dataCategories: [] // App-specific: populate with actual data categories
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return try encoder.encode(report)
}Right to Delete (GDPR Art. 17, CCPA, DPDP)
/// Delete all user data associated with consent categories.
func handleDeletionRequest(consentManager: ConsentManager) async {
// 1. Reset all consent
consentManager.resetAllConsent()
// 2. Delete analytics data
AnalyticsService.shared.deleteAllData()
// 3. Delete marketing/ad data
MarketingService.shared.deleteUserProfile()
// 4. Delete personalization data
RecommendationEngine.shared.clearUserModel()
// 5. Notify backend to delete server-side data
try? await APIClient.shared.requestDataDeletion()
// Note: Keep the audit log — it proves you had consent and then deleted data
}Data Portability (GDPR Art. 20)
/// Export user data in a machine-readable format (JSON).
func exportUserData() throws -> Data {
// Must be in a commonly used, machine-readable format
// JSON or CSV are acceptable
let userData = UserDataExport(
exportDate: Date(),
consent: consentAuditLog.allEntries(),
// Include other personal data categories
profile: userProfile,
activityHistory: activityLog
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted]
return try encoder.encode(userData)
}Audit Trail Requirements
What to Log
Every consent event must record:
| Field | Purpose | Example |
|---|---|---|
| Timestamp | When the decision was made | 2025-03-15T14:30:00Z |
| Category | Which processing category | analytics |
| Decision | Granted or denied | granted |
| Regulation | Which regulation applied | GDPR |
| App Version | For traceability | 2.1.0 |
| OS Version | Environment context | iOS 17.4 |
| Entry ID | Unique identifier | UUID |
Retention Period
- GDPR: Keep consent records for as long as the processing occurs + reasonable period after
- Recommended: 3 years minimum — matches many statute of limitations periods
- Pruning: Periodically remove entries older than retention period
Export Format
Use ISO 8601 dates, JSON structure, and include all fields:
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"category": "analytics",
"status": "granted",
"regulation": "gdpr",
"timestamp": "2025-03-15T14:30:00Z",
"appVersion": "2.1.0",
"osVersion": "Version 17.4 (Build 21E219)"
}
]Common Anti-Patterns
Pre-Checked Consent Boxes
// ❌ Wrong — Pre-checked violates GDPR
@State private var analyticsEnabled = true // Default to true
@State private var marketingEnabled = true // Default to true
// ✅ Right — Default to not determined
// ConsentManager initializes non-essential categories as .notDeterminedConsent Wall (Blocking App Usage)
// ❌ Wrong — App is unusable without consent
if !consentManager.hasConsent(for: .analytics) {
ConsentRequiredView() // Blocks all functionality
}
// ✅ Right — App works without consent, features degrade gracefully
ContentView()
.overlay(alignment: .bottom) {
if consentManager.needsConsent {
ConsentBannerView() // Non-blocking overlay
}
}Nagging After Rejection
// ❌ Wrong — Re-showing consent banner every launch after rejection
.onAppear {
showConsentBanner = true // Always shows, even after explicit rejection
}
// ✅ Right — Only show when not yet determined
.onAppear {
// needsConsent is false once user has made a decision (grant or deny)
}Ignoring ATT Denial
// ❌ Wrong — Tracking despite ATT denial
func trackEvent(_ event: Event) {
// No ATT check — Apple will reject this
analyticsSDK.track(event)
}
// ✅ Right — Check both ATT and app consent
func trackEvent(_ event: Event) {
#if os(iOS)
guard ATTrackingManager.trackingAuthorizationStatus == .authorized else { return }
#endif
guard consentManager.hasConsent(for: .analytics) else { return }
analyticsSDK.track(event)
}Storing Consent Without Audit Trail
// ❌ Wrong — Only stores current state, no history
UserDefaults.standard.set(true, forKey: "analyticsConsent")
// ✅ Right — Records decision with full audit metadata
consentManager.updateConsent(for: .analytics, granted: true)
// Internally logs to ConsentAuditLog with timestamp, regulation, app versionTesting Consent Flows
Unit Testing ConsentManager
@Test
func newManagerNeedsConsent() {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let manager = ConsentManager(defaults: defaults)
#expect(manager.needsConsent == true)
}
@Test
func grantAllResolvesNeedsConsent() {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let manager = ConsentManager(defaults: defaults)
manager.grantAll()
#expect(manager.needsConsent == false)
}
@Test
func consentPersistsAcrossInstances() {
let suiteName = UUID().uuidString
let defaults1 = UserDefaults(suiteName: suiteName)!
let manager1 = ConsentManager(defaults: defaults1)
manager1.updateConsent(for: .analytics, granted: true)
let defaults2 = UserDefaults(suiteName: suiteName)!
let manager2 = ConsentManager(defaults: defaults2)
#expect(manager2.hasConsent(for: .analytics) == true)
}
@Test
func resetReturnsToNotDetermined() {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let manager = ConsentManager(defaults: defaults)
manager.grantAll()
manager.resetAllConsent()
#expect(manager.needsConsent == true)
#expect(manager.hasConsent(for: .analytics) == false)
}Testing Different Regulation Scenarios
@Test
func gdprDefaultsToNotDetermined() {
let manager = ConsentManager(regulation: .gdpr)
for category in ConsentCategory.consentable {
#expect(manager.hasConsent(for: category) == false)
}
}
@Test
func ccpaDefaultsToGranted() {
let manager = ConsentManager(regulation: .ccpa)
for category in ConsentCategory.consentable {
// CCPA is opt-out: default state should be granted
#expect(manager.hasConsent(for: category) == true)
}
}Testing Audit Log
@Test
func auditLogRecordsAllDecisions() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
try! FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let log = ConsentAuditLog(directory: tempDir)
log.record(category: .analytics, granted: true, regulation: .gdpr)
log.record(category: .analytics, granted: false, regulation: .gdpr)
// Allow async write to complete
Thread.sleep(forTimeInterval: 0.1)
let entries = log.allEntries()
#expect(entries.count == 2)
#expect(entries[0].status == .denied) // Most recent first
#expect(entries[1].status == .granted)
}
@Test
func auditLogExportsValidJSON() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let log = ConsentAuditLog(directory: tempDir)
log.record(category: .marketing, granted: true, regulation: .ccpa)
Thread.sleep(forTimeInterval: 0.1)
let jsonData = try log.exportJSON()
let decoded = try JSONDecoder().decode(
[ConsentAuditLog.Entry].self,
from: jsonData
)
#expect(decoded.count == 1)
#expect(decoded[0].category == .marketing)
}Resetting ATT in Simulator
// In test setup, note that ATT always returns .notDetermined in the simulator.
// For device testing:
// 1. Delete and reinstall the app to reset ATT state
// 2. Or reset all privacy settings: Settings > General > Transfer or Reset > Reset Location & Privacy
#if DEBUG
extension ConsentManager {
/// Reset consent state for testing. Only available in debug builds.
func resetForTesting() {
resetAllConsent()
// ATT cannot be programmatically reset — requires app reinstall
}
}
#endifUI Testing Consent Banner
func testConsentBannerAppearsOnFirstLaunch() {
let app = XCUIApplication()
app.launchArguments.append("--reset-consent")
app.launch()
// Banner should be visible
let banner = app.otherElements["consentBanner"]
XCTAssertTrue(banner.waitForExistence(timeout: 3))
// All three buttons should exist
XCTAssertTrue(app.buttons["Accept All"].exists)
XCTAssertTrue(app.buttons["Manage Preferences"].exists)
XCTAssertTrue(app.buttons["Reject Non-Essential"].exists)
}
func testAcceptAllDismissesBanner() {
let app = XCUIApplication()
app.launchArguments.append("--reset-consent")
app.launch()
app.buttons["Accept All"].tap()
let banner = app.otherElements["consentBanner"]
XCTAssertFalse(banner.waitForExistence(timeout: 2))
}
func testManagePreferencesShowsAllCategories() {
let app = XCUIApplication()
app.launchArguments.append("--reset-consent")
app.launch()
app.buttons["Manage Preferences"].tap()
// All categories should be visible
XCTAssertTrue(app.staticTexts["Essential"].exists)
XCTAssertTrue(app.staticTexts["Analytics"].exists)
XCTAssertTrue(app.staticTexts["Marketing"].exists)
XCTAssertTrue(app.staticTexts["Personalization"].exists)
XCTAssertTrue(app.staticTexts["Functional"].exists)
// Essential toggle should be disabled (always on)
let essentialToggle = app.switches["Essential"]
XCTAssertFalse(essentialToggle.isEnabled)
}Consent Flow Code Templates
Production-ready Swift templates for privacy consent management. All code targets iOS 16+ / macOS 13+ (iOS 17+ / macOS 14+ for @Observable) and uses modern Swift concurrency.
ConsentCategory.swift
import Foundation
/// Categories of data processing that require user consent.
///
/// Each category represents a distinct purpose for data collection.
/// Essential is always required and cannot be disabled by the user.
enum ConsentCategory: String, CaseIterable, Codable, Sendable, Identifiable {
case essential
case analytics
case marketing
case personalization
case functional
var id: String { rawValue }
/// Human-readable display name.
var displayName: String {
switch self {
case .essential: return "Essential"
case .analytics: return "Analytics"
case .marketing: return "Marketing"
case .personalization: return "Personalization"
case .functional: return "Functional"
}
}
/// Explanation shown to users describing what this category covers.
var explanation: String {
switch self {
case .essential:
return "Required for the app to function. Includes authentication, security, and core features."
case .analytics:
return "Helps us understand how you use the app so we can improve it. Includes usage statistics and crash reports."
case .marketing:
return "Used for advertising and attribution. Allows us to measure ad effectiveness and show relevant ads."
case .personalization:
return "Enables personalized content and recommendations based on your usage patterns."
case .functional:
return "Remembers your preferences and settings to enhance your experience beyond essential functionality."
}
}
/// Whether this category is required and cannot be disabled.
var isRequired: Bool {
self == .essential
}
/// Categories that require user consent (excludes essential).
static var consentable: [ConsentCategory] {
allCases.filter { !$0.isRequired }
}
}ConsentDecision.swift
import Foundation
/// The user's consent decision for a specific category.
enum ConsentStatus: String, Codable, Sendable {
case granted
case denied
case notDetermined
}
/// A recorded consent decision with metadata.
struct ConsentDecision: Codable, Sendable, Equatable {
let category: ConsentCategory
let status: ConsentStatus
let timestamp: Date
let regulation: ConsentRegulation?
init(
category: ConsentCategory,
status: ConsentStatus,
timestamp: Date = Date(),
regulation: ConsentRegulation? = nil
) {
self.category = category
self.status = status
self.timestamp = timestamp
self.regulation = regulation
}
}
/// Supported privacy regulations.
enum ConsentRegulation: String, Codable, Sendable, CaseIterable {
case gdpr // EU General Data Protection Regulation
case ccpa // California Consumer Privacy Act
case dpdp // India Digital Personal Data Protection
var displayName: String {
switch self {
case .gdpr: return "GDPR"
case .ccpa: return "CCPA"
case .dpdp: return "DPDP"
}
}
/// Whether this regulation requires explicit opt-in consent.
var requiresOptIn: Bool {
switch self {
case .gdpr: return true // Must opt-in
case .ccpa: return false // Opt-out model
case .dpdp: return true // Must opt-in
}
}
/// Minimum age for self-consent.
var minimumConsentAge: Int {
switch self {
case .gdpr: return 16 // Member states can lower to 13
case .ccpa: return 16 // 13 for sale-of-data specific consent
case .dpdp: return 18
}
}
}ConsentManager.swift
import Foundation
import AppTrackingTransparency
/// Manages user privacy consent state across the app.
///
/// Persists consent decisions in UserDefaults and integrates
/// with App Tracking Transparency for iOS.
///
/// Usage:
/// ```swift
/// @State private var consentManager = ConsentManager()
///
/// if consentManager.hasConsent(for: .analytics) {
/// trackEvent(.screenView)
/// }
/// ```
@Observable
final class ConsentManager {
// MARK: - State
private(set) var decisions: [ConsentCategory: ConsentDecision] = [:]
private(set) var attStatus: ATTrackingManager.AuthorizationStatus = .notDetermined
// MARK: - Dependencies
private let defaults: UserDefaults
private let auditLog: ConsentAuditLog
private let regulation: ConsentRegulation?
private static let storageKey = "com.app.consentDecisions"
// MARK: - Computed Properties
/// Whether the app needs to show the consent banner.
///
/// Returns true if any non-essential category has not been decided.
var needsConsent: Bool {
ConsentCategory.consentable.contains { category in
decisions[category]?.status == .notDetermined || decisions[category] == nil
}
}
/// Summary of current consent state for display.
var consentSummary: String {
let granted = ConsentCategory.consentable.filter { hasConsent(for: $0) }
return "\(granted.count) of \(ConsentCategory.consentable.count) optional categories enabled"
}
// MARK: - Initialization
init(
defaults: UserDefaults = .standard,
auditLog: ConsentAuditLog = .shared,
regulation: ConsentRegulation? = nil
) {
self.defaults = defaults
self.auditLog = auditLog
self.regulation = regulation
loadPersistedDecisions()
ensureEssentialGranted()
#if os(iOS)
attStatus = ATTrackingManager.trackingAuthorizationStatus
#endif
}
// MARK: - Consent Operations
/// Check if the user has granted consent for a specific category.
func hasConsent(for category: ConsentCategory) -> Bool {
if category.isRequired { return true }
return decisions[category]?.status == .granted
}
/// Update consent for a specific category.
func updateConsent(for category: ConsentCategory, granted: Bool) {
guard !category.isRequired else { return } // Essential cannot be changed
let decision = ConsentDecision(
category: category,
status: granted ? .granted : .denied,
regulation: regulation
)
decisions[category] = decision
persistDecisions()
auditLog.record(decision: decision)
NotificationCenter.default.post(
name: .consentDidChange,
object: nil,
userInfo: ["category": category, "granted": granted]
)
}
/// Grant consent for all categories.
func grantAll() {
for category in ConsentCategory.consentable {
updateConsent(for: category, granted: true)
}
}
/// Deny consent for all non-essential categories.
func denyAllNonEssential() {
for category in ConsentCategory.consentable {
updateConsent(for: category, granted: false)
}
}
/// Reset all consent decisions, requiring re-consent.
func resetAllConsent() {
for category in ConsentCategory.consentable {
let decision = ConsentDecision(
category: category,
status: .notDetermined,
regulation: regulation
)
decisions[category] = decision
auditLog.record(decision: decision)
}
persistDecisions()
NotificationCenter.default.post(name: .consentDidChange, object: nil)
}
/// Execute a closure only if consent is granted for the given category.
func executeIfConsented(
category: ConsentCategory,
action: () -> Void
) {
guard hasConsent(for: category) else { return }
action()
}
// MARK: - ATT Integration
/// Request App Tracking Transparency permission.
///
/// Should be called after showing your own consent explanation,
/// not during app launch. Returns the authorization status.
@MainActor
func requestATTPermission() async -> ATTrackingManager.AuthorizationStatus {
#if os(iOS)
let status = await ATTrackingManager.requestTrackingAuthorization()
attStatus = status
// Sync ATT result with marketing consent
switch status {
case .authorized:
updateConsent(for: .marketing, granted: true)
case .denied, .restricted:
updateConsent(for: .marketing, granted: false)
case .notDetermined:
break
@unknown default:
break
}
return status
#else
return .notDetermined
#endif
}
/// Whether ATT has been requested and resolved.
var isATTDetermined: Bool {
#if os(iOS)
return attStatus != .notDetermined
#else
return true
#endif
}
// MARK: - Persistence
private func loadPersistedDecisions() {
guard let data = defaults.data(forKey: Self.storageKey),
let decoded = try? JSONDecoder().decode(
[String: ConsentDecision].self, from: data
) else {
initializeDefaultDecisions()
return
}
decisions = Dictionary(
uniqueKeysWithValues: decoded.compactMap { key, value in
guard let category = ConsentCategory(rawValue: key) else { return nil }
return (category, value)
}
)
}
private func persistDecisions() {
let encoded = Dictionary(
uniqueKeysWithValues: decisions.map { ($0.key.rawValue, $0.value) }
)
if let data = try? JSONEncoder().encode(encoded) {
defaults.set(data, forKey: Self.storageKey)
}
}
private func initializeDefaultDecisions() {
for category in ConsentCategory.allCases {
decisions[category] = ConsentDecision(
category: category,
status: category.isRequired ? .granted : .notDetermined
)
}
}
private func ensureEssentialGranted() {
decisions[.essential] = ConsentDecision(
category: .essential,
status: .granted
)
}
}
// MARK: - Notifications
extension Notification.Name {
/// Posted when any consent decision changes.
///
/// UserInfo contains "category" (ConsentCategory) and "granted" (Bool).
static let consentDidChange = Notification.Name("consentDidChange")
}ConsentBannerView.swift
import SwiftUI
/// A bottom banner view requesting user consent for data processing.
///
/// Displays a brief explanation with three action buttons:
/// - Accept All: grants all consent categories
/// - Manage Preferences: opens detailed preferences view
/// - Reject Non-Essential: denies all optional categories
///
/// Usage:
/// ```swift
/// .overlay(alignment: .bottom) {
/// if consentManager.needsConsent {
/// ConsentBannerView()
/// .transition(.move(edge: .bottom).combined(with: .opacity))
/// }
/// }
/// ```
struct ConsentBannerView: View {
@Environment(ConsentManager.self) private var consentManager
@State private var showPreferences = false
var body: some View {
VStack(spacing: 16) {
// Header
HStack {
Image(systemName: "hand.raised.fill")
.font(.title2)
.foregroundStyle(.tint)
Text("Your Privacy Matters")
.font(.headline)
Spacer()
}
// Explanation
Text("We use cookies and similar technologies to improve your experience. You can choose which categories of data processing to allow.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
// Action Buttons
VStack(spacing: 10) {
Button {
consentManager.grantAll()
} label: {
Text("Accept All")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
HStack(spacing: 10) {
Button {
showPreferences = true
} label: {
Text("Manage Preferences")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.regular)
Button {
consentManager.denyAllNonEssential()
} label: {
Text("Reject Non-Essential")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.regular)
}
}
// Privacy Policy Link
Link("Privacy Policy", destination: URL(string: "https://example.com/privacy")!)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(20)
.background {
RoundedRectangle(cornerRadius: 16)
#if os(iOS)
.fill(.ultraThinMaterial)
#else
.fill(Color(nsColor: .controlBackgroundColor))
#endif
.shadow(color: .black.opacity(0.15), radius: 10, y: -5)
}
.padding(.horizontal, 16)
.padding(.bottom, 8)
.sheet(isPresented: $showPreferences) {
ConsentPreferencesView()
}
}
}ConsentPreferencesView.swift
import SwiftUI
/// Detailed consent preferences view with per-category toggles.
///
/// Shows each consent category with its description and a toggle.
/// Essential category is always on with a disabled toggle.
/// Provides Save and Cancel actions.
///
/// Usage:
/// ```swift
/// .sheet(isPresented: $showPreferences) {
/// ConsentPreferencesView()
/// .environment(consentManager)
/// }
/// ```
struct ConsentPreferencesView: View {
@Environment(ConsentManager.self) private var consentManager
@Environment(\.dismiss) private var dismiss
@State private var pendingDecisions: [ConsentCategory: Bool] = [:]
var body: some View {
NavigationStack {
List {
Section {
explanationHeader
}
Section("Consent Categories") {
ForEach(ConsentCategory.allCases) { category in
ConsentCategoryRow(
category: category,
isEnabled: binding(for: category)
)
}
}
Section {
quickActions
}
Section {
privacyLinks
}
}
.navigationTitle("Privacy Preferences")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
savePreferences()
dismiss()
}
.fontWeight(.semibold)
}
}
.onAppear {
loadCurrentDecisions()
}
}
}
// MARK: - Subviews
private var explanationHeader: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Choose which types of data processing you consent to. Essential data processing cannot be disabled as it is required for the app to function.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
private var quickActions: some View {
VStack(spacing: 8) {
Button("Enable All") {
for category in ConsentCategory.consentable {
pendingDecisions[category] = true
}
}
Button("Disable All Non-Essential") {
for category in ConsentCategory.consentable {
pendingDecisions[category] = false
}
}
.foregroundStyle(.secondary)
}
}
private var privacyLinks: some View {
VStack(alignment: .leading, spacing: 8) {
Link("Privacy Policy", destination: URL(string: "https://example.com/privacy")!)
Link("Terms of Service", destination: URL(string: "https://example.com/terms")!)
}
.font(.footnote)
}
// MARK: - Logic
private func binding(for category: ConsentCategory) -> Binding<Bool> {
Binding(
get: { pendingDecisions[category] ?? category.isRequired },
set: { newValue in
guard !category.isRequired else { return }
pendingDecisions[category] = newValue
}
)
}
private func loadCurrentDecisions() {
for category in ConsentCategory.allCases {
pendingDecisions[category] = consentManager.hasConsent(for: category)
}
}
private func savePreferences() {
for (category, granted) in pendingDecisions {
guard !category.isRequired else { continue }
consentManager.updateConsent(for: category, granted: granted)
}
}
}
// MARK: - Category Row
/// A single row in the consent preferences list.
struct ConsentCategoryRow: View {
let category: ConsentCategory
@Binding var isEnabled: Bool
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(category.displayName)
.font(.body.weight(.medium))
if category.isRequired {
Text("Required")
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.secondary.opacity(0.2))
.clipShape(Capsule())
}
}
}
Spacer()
Toggle("", isOn: $isEnabled)
.labelsHidden()
.disabled(category.isRequired)
}
Text(category.explanation)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 4)
}
}ConsentAuditLog.swift
import Foundation
/// Records all consent decisions for compliance auditing.
///
/// Persists entries as JSON in the app's documents directory.
/// Supports export for data subject access requests (DSAR).
///
/// Usage:
/// ```swift
/// let log = ConsentAuditLog.shared
/// log.record(decision: decision)
/// let jsonData = try log.exportJSON()
/// ```
final class ConsentAuditLog: Sendable {
static let shared = ConsentAuditLog()
private let fileURL: URL
private let queue = DispatchQueue(label: "com.app.consentAuditLog", qos: .utility)
// MARK: - Audit Entry
struct Entry: Codable, Sendable, Identifiable {
let id: UUID
let category: ConsentCategory
let status: ConsentStatus
let regulation: ConsentRegulation?
let timestamp: Date
let appVersion: String
let osVersion: String
init(
category: ConsentCategory,
status: ConsentStatus,
regulation: ConsentRegulation?,
timestamp: Date = Date()
) {
self.id = UUID()
self.category = category
self.status = status
self.regulation = regulation
self.timestamp = timestamp
self.appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
self.osVersion = ProcessInfo.processInfo.operatingSystemVersionString
}
}
// MARK: - Initialization
init(directory: URL? = nil) {
let dir = directory ?? FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
self.fileURL = dir.appendingPathComponent("consent_audit_log.json")
}
// MARK: - Recording
/// Record a consent decision in the audit log.
func record(decision: ConsentDecision) {
let entry = Entry(
category: decision.category,
status: decision.status,
regulation: decision.regulation
)
record(entry: entry)
}
/// Record a consent decision by components.
func record(
category: ConsentCategory,
granted: Bool,
regulation: ConsentRegulation? = nil
) {
let entry = Entry(
category: category,
status: granted ? .granted : .denied,
regulation: regulation
)
record(entry: entry)
}
private func record(entry: Entry) {
queue.async { [fileURL] in
var entries = Self.loadEntries(from: fileURL)
entries.append(entry)
Self.saveEntries(entries, to: fileURL)
}
}
// MARK: - Querying
/// All recorded audit entries, sorted by timestamp (newest first).
func allEntries() -> [Entry] {
Self.loadEntries(from: fileURL).sorted { $0.timestamp > $1.timestamp }
}
/// Entries filtered by category.
func entries(for category: ConsentCategory) -> [Entry] {
allEntries().filter { $0.category == category }
}
/// The most recent decision for each category.
func currentDecisions() -> [ConsentCategory: Entry] {
var result: [ConsentCategory: Entry] = [:]
for entry in allEntries() {
if result[entry.category] == nil {
result[entry.category] = entry
}
}
return result
}
// MARK: - Export
/// Export the full audit log as JSON data.
///
/// Use for data subject access requests (DSAR) or compliance reporting.
func exportJSON() throws -> Data {
let entries = allEntries()
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return try encoder.encode(entries)
}
/// Export as a human-readable string for display.
func exportReadable() -> String {
let entries = allEntries()
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .medium
return entries.map { entry in
let action = entry.status == .granted ? "Granted" : "Denied"
let date = formatter.string(from: entry.timestamp)
let reg = entry.regulation?.displayName ?? "—"
return "[\(date)] \(action) \(entry.category.displayName) (Regulation: \(reg), App: \(entry.appVersion))"
}.joined(separator: "\n")
}
// MARK: - Maintenance
/// Remove entries older than the specified retention period.
///
/// Default retention: 3 years (GDPR recommendation).
func pruneOldEntries(olderThan retention: TimeInterval = 3 * 365 * 24 * 3600) {
queue.async { [fileURL] in
let cutoff = Date().addingTimeInterval(-retention)
var entries = Self.loadEntries(from: fileURL)
entries.removeAll { $0.timestamp < cutoff }
Self.saveEntries(entries, to: fileURL)
}
}
/// Delete the entire audit log.
func deleteAll() {
queue.async { [fileURL] in
try? FileManager.default.removeItem(at: fileURL)
}
}
// MARK: - File Operations
private static func loadEntries(from fileURL: URL) -> [Entry] {
guard let data = try? Data(contentsOf: fileURL) else { return [] }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return (try? decoder.decode([Entry].self, from: data)) ?? []
}
private static func saveEntries(_ entries: [Entry], to fileURL: URL) {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
guard let data = try? encoder.encode(entries) else { return }
try? data.write(to: fileURL, options: .atomic)
}
}