Volver al blog
Technical

Mobile App Consent: Navigating ATT, TCF, and Google Play Data Safety

Equipo de GetCookiesNovember 16, 202516 min de lectura
MobileiOSAndroidATTSDK

TLDR: Mobile consent requires native integration, respecting platform privacy controls, and coordinating with ATT/Android Privacy Sandbox.

Read full summary

Technical guide to implementing consent on iOS and Android. Covers native SDK integration, App Tracking Transparency coordination, deep linking consent, and maintaining consent state across app and web.

Summary by Claude AI


title: "iOS & Android Mobile Consent: Complete ATT and Privacy Sandbox Implementation Guide 2025" description: "Master mobile consent implementation with this comprehensive guide to iOS App Tracking Transparency, Android Privacy Sandbox, CMP SDK integration, and cross-platform compliance strategies." keywords: ["app tracking transparency", "ATT implementation", "android privacy sandbox", "mobile consent", "mobile CMP SDK", "IDFA consent", "mobile privacy compliance"] author: "GetCookies Privacy Team" date: "2025-01-15" category: "Mobile Development" featured: true readingTime: "24 min read"

What is App Tracking Transparency (ATT)?

ATT is an Apple framework requiring apps to ask for permission before tracking the user's activity across other companies' apps and websites. This is the famous "Ask App Not to Track" popup on iOS. When a user taps "Ask App Not to Track," your app loses access to the IDFA (Identifier for Advertisers) and must respect the user's choice by not engaging in any form of cross-app tracking.

Introduction: The Mobile Privacy Paradigm Shift

Mobile apps face a "double layer" of consent: the operating system layer (iOS ATT, Android permissions) and the regulatory layer (GDPR/CCPA banners). This creates a complex landscape where developers must coordinate multiple consent mechanisms while maintaining a seamless user experience.

The introduction of iOS 14.5 in April 2021 fundamentally changed mobile advertising and analytics. App Tracking Transparency wasn't just a technical change—it was a philosophical shift in how Apple views user privacy. Google followed with the Android Privacy Sandbox initiative, signaling that both major platforms are moving toward a privacy-first future.

This guide provides everything you need to implement compliant, user-friendly consent flows on both platforms while maintaining the analytics and advertising capabilities your business needs.

The Mobile Privacy Landscape 2025

Platform Privacy Initiative Status Key Changes
iOS App Tracking Transparency Mandatory since iOS 14.5 IDFA requires user consent
iOS Privacy Nutrition Labels Mandatory App Store disclosure requirements
iOS App Privacy Report Available User can see tracking activity
Android Privacy Sandbox Rolling out 2024-2025 Topics, Attribution Reporting, FLEDGE
Android GAID Deprecation Planned 2024+ Advertising ID phase-out
Both GDPR/CCPA Mandatory Regulatory consent requirements

iOS App Tracking Transparency: Deep Implementation

Understanding ATT Architecture

// ATTManager.swift
import AppTrackingTransparency
import AdSupport

/// Comprehensive ATT implementation with pre-permission flow
class ATTManager {
    static let shared = ATTManager()

    // MARK: - Types

    enum ATTStatus {
        case notDetermined
        case restricted
        case denied
        case authorized

        var canTrack: Bool {
            return self == .authorized
        }

        var shouldShowPrePermission: Bool {
            return self == .notDetermined
        }

        init(from status: ATTrackingManager.AuthorizationStatus) {
            switch status {
            case .notDetermined: self = .notDetermined
            case .restricted: self = .restricted
            case .denied: self = .denied
            case .authorized: self = .authorized
            @unknown default: self = .notDetermined
            }
        }
    }

    struct ATTConfiguration {
        let showPrePermissionScreen: Bool
        let prePermissionTitle: String
        let prePermissionMessage: String
        let prePermissionBenefits: [String]
        let delayAfterAppLaunch: TimeInterval
        let requireCMPConsentFirst: Bool

        static var `default`: ATTConfiguration {
            ATTConfiguration(
                showPrePermissionScreen: true,
                prePermissionTitle: "Allow Tracking?",
                prePermissionMessage: "We use your data to show you relevant ads and improve your experience. You can change this anytime in Settings.",
                prePermissionBenefits: [
                    "See ads that match your interests",
                    "Help support free content",
                    "Get personalized recommendations"
                ],
                delayAfterAppLaunch: 2.0,
                requireCMPConsentFirst: true
            )
        }
    }

    // MARK: - Properties

    private var configuration: ATTConfiguration
    private var cmpConsentGranted = false
    private var attCompletionHandler: ((ATTStatus) -> Void)?

    // Track consent for analytics
    private(set) var lastATTRequestDate: Date?
    private(set) var attRequestCount = 0

    // MARK: - Initialization

    private init() {
        self.configuration = .default
    }

    func configure(with configuration: ATTConfiguration) {
        self.configuration = configuration
    }

    // MARK: - Public API

    /// Get current ATT status without prompting
    var currentStatus: ATTStatus {
        ATTStatus(from: ATTrackingManager.trackingAuthorizationStatus)
    }

    /// Get IDFA if authorized, nil otherwise
    var idfa: String? {
        guard currentStatus.canTrack else { return nil }

        let idfa = ASIdentifierManager.shared().advertisingIdentifier.uuidString

        // Check for zeroed IDFA (indicates tracking is off)
        guard idfa != "00000000-0000-0000-0000-000000000000" else { return nil }

        return idfa
    }

    /// Check if Limited Ad Tracking is enabled
    var isLimitedAdTrackingEnabled: Bool {
        return ASIdentifierManager.shared().isAdvertisingTrackingEnabled == false
    }

    /// Request ATT authorization with optional pre-permission screen
    func requestAuthorization(
        from viewController: UIViewController? = nil,
        completion: @escaping (ATTStatus) -> Void
    ) {
        // Check if we need CMP consent first
        if configuration.requireCMPConsentFirst && !cmpConsentGranted {
            // Wait for CMP consent
            NotificationCenter.default.addObserver(
                forName: .cmpConsentUpdated,
                object: nil,
                queue: .main
            ) { [weak self] notification in
                guard let consent = notification.userInfo?["consent"] as? CMPConsent,
                      consent.analyticsAllowed else {
                    completion(.denied)
                    return
                }
                self?.cmpConsentGranted = true
                self?.continueATTFlow(from: viewController, completion: completion)
            }
            return
        }

        continueATTFlow(from: viewController, completion: completion)
    }

    private func continueATTFlow(
        from viewController: UIViewController?,
        completion: @escaping (ATTStatus) -> Void
    ) {
        let currentStatus = self.currentStatus

        switch currentStatus {
        case .notDetermined:
            if configuration.showPrePermissionScreen, let vc = viewController {
                showPrePermissionScreen(from: vc) { [weak self] userProceed in
                    if userProceed {
                        self?.requestSystemATTPrompt(completion: completion)
                    } else {
                        // User declined pre-permission, don't show system prompt
                        completion(.denied)
                        self?.trackATTDecision(status: .denied, source: "pre_permission")
                    }
                }
            } else {
                requestSystemATTPrompt(completion: completion)
            }

        case .restricted, .denied, .authorized:
            completion(currentStatus)
        }
    }

    // MARK: - Pre-Permission Screen

    private func showPrePermissionScreen(
        from viewController: UIViewController,
        completion: @escaping (Bool) -> Void
    ) {
        let prePermissionVC = ATTPrePermissionViewController(
            configuration: configuration,
            completion: completion
        )
        prePermissionVC.modalPresentationStyle = .overFullScreen
        prePermissionVC.modalTransitionStyle = .crossDissolve
        viewController.present(prePermissionVC, animated: true)
    }

    // MARK: - System ATT Prompt

    private func requestSystemATTPrompt(completion: @escaping (ATTStatus) -> Void) {
        attRequestCount += 1
        lastATTRequestDate = Date()

        ATTrackingManager.requestTrackingAuthorization { [weak self] status in
            DispatchQueue.main.async {
                let attStatus = ATTStatus(from: status)
                completion(attStatus)
                self?.trackATTDecision(status: attStatus, source: "system_prompt")
                self?.handleATTResult(attStatus)
            }
        }
    }

    // MARK: - Post-ATT Handling

    private func handleATTResult(_ status: ATTStatus) {
        switch status {
        case .authorized:
            // Enable tracking pixels and attribution
            enableTracking()

        case .denied, .restricted:
            // Disable tracking, use privacy-preserving alternatives
            disableTracking()

        case .notDetermined:
            // Should not happen after request
            break
        }

        // Notify observers
        NotificationCenter.default.post(
            name: .attStatusChanged,
            object: nil,
            userInfo: ["status": status]
        )
    }

    private func enableTracking() {
        guard let idfa = idfa else { return }

        // Configure Facebook SDK
        #if canImport(FBSDKCoreKit)
        FBSDKCoreKit.Settings.shared.isAdvertiserIDCollectionEnabled = true
        FBSDKCoreKit.Settings.shared.isAutoLogAppEventsEnabled = true
        #endif

        // Configure Google Analytics
        #if canImport(FirebaseAnalytics)
        FirebaseAnalytics.Analytics.setAnalyticsCollectionEnabled(true)
        FirebaseAnalytics.Analytics.setUserProperty(idfa, forName: "idfa")
        #endif

        // Configure AppsFlyer
        #if canImport(AppsFlyerLib)
        AppsFlyerLib.shared().waitForATTUserAuthorization(timeoutInterval: 60)
        #endif

        // Configure Adjust
        #if canImport(Adjust)
        Adjust.requestTrackingAuthorization(completionHandler: nil)
        #endif

        print("Tracking enabled with IDFA: \(idfa)")
    }

    private func disableTracking() {
        // Disable Facebook SDK tracking
        #if canImport(FBSDKCoreKit)
        FBSDKCoreKit.Settings.shared.isAdvertiserIDCollectionEnabled = false
        #endif

        // Disable Google Analytics
        #if canImport(FirebaseAnalytics)
        // Still allow analytics, just without IDFA
        FirebaseAnalytics.Analytics.setAnalyticsCollectionEnabled(true)
        #endif

        // Use SKAdNetwork for attribution
        enableSKAdNetwork()

        print("Cross-app tracking disabled, using SKAdNetwork for attribution")
    }

    private func enableSKAdNetwork() {
        // SKAdNetwork is automatic in iOS 14+
        // Register for ad network attribution
        #if canImport(StoreKit)
        if #available(iOS 15.4, *) {
            // iOS 15.4+ supports SKAdNetwork 4.0
            print("SKAdNetwork 4.0 available")
        }
        #endif
    }

    // MARK: - Analytics

    private func trackATTDecision(status: ATTStatus, source: String) {
        let event: [String: Any] = [
            "event_name": "att_decision",
            "status": String(describing: status),
            "source": source,
            "timestamp": ISO8601DateFormatter().string(from: Date()),
            "app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown",
            "ios_version": UIDevice.current.systemVersion,
            "request_count": attRequestCount
        ]

        // Send to your analytics (first-party, consent-agnostic)
        AnalyticsService.shared.track(event: event)
    }
}

// MARK: - Notifications

extension Notification.Name {
    static let attStatusChanged = Notification.Name("attStatusChanged")
    static let cmpConsentUpdated = Notification.Name("cmpConsentUpdated")
}

// MARK: - Pre-Permission View Controller

class ATTPrePermissionViewController: UIViewController {
    private let configuration: ATTManager.ATTConfiguration
    private let completion: (Bool) -> Void

    private lazy var containerView: UIView = {
        let view = UIView()
        view.backgroundColor = .systemBackground
        view.layer.cornerRadius = 16
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()

    private lazy var titleLabel: UILabel = {
        let label = UILabel()
        label.text = configuration.prePermissionTitle
        label.font = .systemFont(ofSize: 24, weight: .bold)
        label.textAlignment = .center
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()

    private lazy var messageLabel: UILabel = {
        let label = UILabel()
        label.text = configuration.prePermissionMessage
        label.font = .systemFont(ofSize: 16)
        label.textColor = .secondaryLabel
        label.textAlignment = .center
        label.numberOfLines = 0
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()

    private lazy var benefitsStack: UIStackView = {
        let stack = UIStackView()
        stack.axis = .vertical
        stack.spacing = 12
        stack.translatesAutoresizingMaskIntoConstraints = false

        for benefit in configuration.prePermissionBenefits {
            let label = UILabel()
            label.text = "✓ \(benefit)"
            label.font = .systemFont(ofSize: 14)
            label.textColor = .label
            stack.addArrangedSubview(label)
        }

        return stack
    }()

    private lazy var continueButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("Continue", for: .normal)
        button.titleLabel?.font = .systemFont(ofSize: 18, weight: .semibold)
        button.backgroundColor = .systemBlue
        button.setTitleColor(.white, for: .normal)
        button.layer.cornerRadius = 12
        button.translatesAutoresizingMaskIntoConstraints = false
        button.addTarget(self, action: #selector(continueTapped), for: .touchUpInside)
        return button
    }()

    private lazy var notNowButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("Not Now", for: .normal)
        button.titleLabel?.font = .systemFont(ofSize: 16)
        button.setTitleColor(.secondaryLabel, for: .normal)
        button.translatesAutoresizingMaskIntoConstraints = false
        button.addTarget(self, action: #selector(notNowTapped), for: .touchUpInside)
        return button
    }()

    init(configuration: ATTManager.ATTConfiguration, completion: @escaping (Bool) -> Void) {
        self.configuration = configuration
        self.completion = completion
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }

    private func setupUI() {
        view.backgroundColor = UIColor.black.withAlphaComponent(0.5)

        view.addSubview(containerView)
        containerView.addSubview(titleLabel)
        containerView.addSubview(messageLabel)
        containerView.addSubview(benefitsStack)
        containerView.addSubview(continueButton)
        containerView.addSubview(notNowButton)

        NSLayoutConstraint.activate([
            containerView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
            containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),

            titleLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 24),
            titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16),
            titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16),

            messageLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16),
            messageLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16),
            messageLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16),

            benefitsStack.topAnchor.constraint(equalTo: messageLabel.bottomAnchor, constant: 24),
            benefitsStack.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 24),
            benefitsStack.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -24),

            continueButton.topAnchor.constraint(equalTo: benefitsStack.bottomAnchor, constant: 24),
            continueButton.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16),
            continueButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16),
            continueButton.heightAnchor.constraint(equalToConstant: 50),

            notNowButton.topAnchor.constraint(equalTo: continueButton.bottomAnchor, constant: 12),
            notNowButton.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
            notNowButton.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -24)
        ])
    }

    @objc private func continueTapped() {
        dismiss(animated: true) { [weak self] in
            self?.completion(true)
        }
    }

    @objc private func notNowTapped() {
        dismiss(animated: true) { [weak self] in
            self?.completion(false)
        }
    }
}

Optimal ATT Timing and Flow

// ATTFlowCoordinator.swift
import UIKit

/// Coordinates the consent flow across CMP and ATT
class ConsentFlowCoordinator {
    static let shared = ConsentFlowCoordinator()

    enum ConsentState {
        case initial
        case showingCMP
        case cmpComplete(CMPConsent)
        case showingATT
        case complete(CMPConsent, ATTManager.ATTStatus)
    }

    private var currentState: ConsentState = .initial
    private var completionHandler: ((CMPConsent, ATTManager.ATTStatus) -> Void)?

    /// Best Practice Flow:
    /// 1. App Launch
    /// 2. Show CMP Banner (Soft explain: "We use data to show relevant ads...")
    /// 3. User accepts CMP
    /// 4. Show ATT Prompt ("Allow app to track?")
    /// 5. If ATT denied, CMP must respect that and signal "no consent" for ad identifiers
    func startConsentFlow(
        from viewController: UIViewController,
        completion: @escaping (CMPConsent, ATTManager.ATTStatus) -> Void
    ) {
        self.completionHandler = completion
        self.currentState = .initial

        // Check if we already have consent
        if let existingConsent = CMPManager.shared.currentConsent {
            let attStatus = ATTManager.shared.currentStatus

            if attStatus != .notDetermined {
                // Already have both consents
                completion(existingConsent, attStatus)
                return
            }

            // Have CMP but need ATT
            currentState = .cmpComplete(existingConsent)
            showATTIfNeeded(from: viewController)
            return
        }

        // Start fresh consent flow
        showCMP(from: viewController)
    }

    private func showCMP(from viewController: UIViewController) {
        currentState = .showingCMP

        CMPManager.shared.showConsentBanner(from: viewController) { [weak self] consent in
            self?.currentState = .cmpComplete(consent)

            if consent.analyticsAllowed {
                // User accepted analytics, now ask for ATT
                self?.showATTIfNeeded(from: viewController)
            } else {
                // User rejected CMP, don't bother with ATT
                self?.completeFlow(
                    consent: consent,
                    attStatus: .denied
                )
            }
        }
    }

    private func showATTIfNeeded(from viewController: UIViewController) {
        guard case .cmpComplete(let consent) = currentState else { return }

        // Only show ATT if CMP analytics was accepted
        guard consent.analyticsAllowed else {
            completeFlow(consent: consent, attStatus: .denied)
            return
        }

        currentState = .showingATT

        // Delay slightly for better UX
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            ATTManager.shared.requestAuthorization(from: viewController) { [weak self] status in
                self?.completeFlow(consent: consent, attStatus: status)
            }
        }
    }

    private func completeFlow(consent: CMPConsent, attStatus: ATTManager.ATTStatus) {
        currentState = .complete(consent, attStatus)

        // If ATT denied but CMP accepted marketing, we need to reconcile
        var finalConsent = consent
        if !attStatus.canTrack && consent.marketingAllowed {
            // Can't do cross-app tracking, downgrade consent
            finalConsent = CMPConsent(
                analyticsAllowed: consent.analyticsAllowed,
                marketingAllowed: false, // Downgrade due to ATT
                personalizationAllowed: consent.personalizationAllowed,
                thirdPartyAllowed: false, // No third-party without ATT
                timestamp: Date()
            )

            // Update CMP with new status
            CMPManager.shared.updateConsent(finalConsent)
        }

        completionHandler?(finalConsent, attStatus)

        // Notify app-wide
        NotificationCenter.default.post(
            name: .consentFlowCompleted,
            object: nil,
            userInfo: [
                "cmpConsent": finalConsent,
                "attStatus": attStatus
            ]
        )
    }
}

// MARK: - Supporting Types

struct CMPConsent {
    let analyticsAllowed: Bool
    let marketingAllowed: Bool
    let personalizationAllowed: Bool
    let thirdPartyAllowed: Bool
    let timestamp: Date

    static var denied: CMPConsent {
        CMPConsent(
            analyticsAllowed: false,
            marketingAllowed: false,
            personalizationAllowed: false,
            thirdPartyAllowed: false,
            timestamp: Date()
        )
    }
}

extension Notification.Name {
    static let consentFlowCompleted = Notification.Name("consentFlowCompleted")
}

Android Privacy Sandbox Implementation

Android is moving away from the GAID (Google Advertising ID) toward the Privacy Sandbox for Android. This requires apps to use new APIs instead of tracking individual identifiers.

Topics API Implementation

// TopicsApiManager.kt
import android.content.Context
import android.os.Build
import androidx.privacysandbox.ads.adservices.topics.GetTopicsRequest
import androidx.privacysandbox.ads.adservices.topics.GetTopicsResponse
import androidx.privacysandbox.ads.adservices.topics.Topic
import androidx.privacysandbox.ads.adservices.topics.TopicsManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

/**
 * Android Privacy Sandbox Topics API implementation
 *
 * Topics API provides interest-based advertising without cross-app tracking.
 * Topics are derived from app usage and stored on-device.
 */
class TopicsApiManager(private val context: Context) {

    private var topicsManager: TopicsManager? = null

    data class TopicsResult(
        val topics: List<TopicInfo>,
        val isAvailable: Boolean,
        val error: String? = null
    )

    data class TopicInfo(
        val topicId: Int,
        val taxonomyVersion: Long,
        val modelVersion: Long
    )

    companion object {
        private const val TAG = "TopicsApiManager"

        // Minimum Android version for Privacy Sandbox
        val isPrivacySandboxAvailable: Boolean
            get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
    }

    /**
     * Initialize Topics API
     */
    suspend fun initialize(): Boolean = withContext(Dispatchers.IO) {
        if (!isPrivacySandboxAvailable) {
            return@withContext false
        }

        try {
            topicsManager = TopicsManager.obtain(context)
            true
        } catch (e: Exception) {
            android.util.Log.e(TAG, "Failed to initialize Topics API", e)
            false
        }
    }

    /**
     * Get user's topics for interest-based advertising
     */
    suspend fun getTopics(
        adsSdkName: String,
        shouldRecordObservation: Boolean = true
    ): TopicsResult = withContext(Dispatchers.IO) {
        val manager = topicsManager ?: return@withContext TopicsResult(
            topics = emptyList(),
            isAvailable = false,
            error = "Topics API not initialized"
        )

        try {
            val request = GetTopicsRequest.Builder()
                .setAdsSdkName(adsSdkName)
                .setShouldRecordObservation(shouldRecordObservation)
                .build()

            val response = manager.getTopics(request)

            TopicsResult(
                topics = response.topics.map { topic ->
                    TopicInfo(
                        topicId = topic.topicId,
                        taxonomyVersion = topic.taxonomyVersion,
                        modelVersion = topic.modelVersion
                    )
                },
                isAvailable = true
            )
        } catch (e: Exception) {
            android.util.Log.e(TAG, "Failed to get topics", e)
            TopicsResult(
                topics = emptyList(),
                isAvailable = false,
                error = e.message
            )
        }
    }

    /**
     * Map topic IDs to human-readable categories
     */
    fun getTopicCategory(topicId: Int): String {
        // Topics taxonomy v1 mapping (subset)
        return when (topicId) {
            1 -> "Arts & Entertainment"
            2 -> "Autos & Vehicles"
            3 -> "Beauty & Fitness"
            4 -> "Books & Literature"
            5 -> "Business & Industrial"
            6 -> "Computers & Electronics"
            7 -> "Finance"
            8 -> "Food & Drink"
            9 -> "Games"
            10 -> "Health"
            11 -> "Hobbies & Leisure"
            12 -> "Home & Garden"
            13 -> "Internet & Telecom"
            14 -> "Jobs & Education"
            15 -> "Law & Government"
            16 -> "News"
            17 -> "Online Communities"
            18 -> "People & Society"
            19 -> "Pets & Animals"
            20 -> "Real Estate"
            21 -> "Reference"
            22 -> "Science"
            23 -> "Shopping"
            24 -> "Sports"
            25 -> "Travel"
            else -> "Unknown"
        }
    }
}

/**
 * Attribution Reporting API implementation
 */
class AttributionReportingManager(private val context: Context) {

    data class AttributionSource(
        val destinationUrl: String,
        val sourceEventId: String,
        val expiry: Long? = null,
        val sourceType: SourceType = SourceType.EVENT
    )

    enum class SourceType {
        EVENT,      // Click-based attribution
        NAVIGATION  // View-based attribution
    }

    data class AttributionTrigger(
        val triggerData: String,
        val destinationUrl: String
    )

    /**
     * Register an attribution source (e.g., ad click)
     */
    suspend fun registerAttributionSource(
        source: AttributionSource
    ): Boolean = withContext(Dispatchers.IO) {
        if (!TopicsApiManager.isPrivacySandboxAvailable) {
            return@withContext false
        }

        try {
            // Use Attribution Reporting API
            // Note: This requires adding the privacysandbox-ads dependency
            val measurementManager = androidx.privacysandbox.ads.adservices.measurement
                .MeasurementManager.obtain(context)

            // Register the source
            // Implementation details depend on specific API version

            true
        } catch (e: Exception) {
            android.util.Log.e("Attribution", "Failed to register source", e)
            false
        }
    }

    /**
     * Register a conversion trigger (e.g., purchase)
     */
    suspend fun registerTrigger(
        trigger: AttributionTrigger
    ): Boolean = withContext(Dispatchers.IO) {
        if (!TopicsApiManager.isPrivacySandboxAvailable) {
            return@withContext false
        }

        try {
            // Register the conversion trigger
            // Implementation details depend on specific API version

            true
        } catch (e: Exception) {
            android.util.Log.e("Attribution", "Failed to register trigger", e)
            false
        }
    }
}

Android Consent Manager

// AndroidConsentManager.kt
import android.content.Context
import android.content.SharedPreferences
import androidx.appcompat.app.AppCompatActivity
import com.google.android.gms.ads.identifier.AdvertisingIdClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

/**
 * Comprehensive Android consent manager handling:
 * - GDPR/CCPA consent (via CMP)
 * - GAID consent (opt-out of personalized ads)
 * - Privacy Sandbox APIs
 */
class AndroidConsentManager(private val context: Context) {

    private val prefs: SharedPreferences = context.getSharedPreferences(
        PREFS_NAME, Context.MODE_PRIVATE
    )

    // MARK: - Types

    data class ConsentState(
        val analyticsAllowed: Boolean,
        val marketingAllowed: Boolean,
        val personalizationAllowed: Boolean,
        val gaidAllowed: Boolean,
        val privacySandboxEnabled: Boolean,
        val timestamp: Long
    )

    enum class ConsentPurpose {
        ANALYTICS,
        MARKETING,
        PERSONALIZATION,
        GAID_ACCESS,
        PRIVACY_SANDBOX
    }

    // MARK: - Current State

    val currentConsent: ConsentState
        get() = ConsentState(
            analyticsAllowed = prefs.getBoolean(KEY_ANALYTICS, false),
            marketingAllowed = prefs.getBoolean(KEY_MARKETING, false),
            personalizationAllowed = prefs.getBoolean(KEY_PERSONALIZATION, false),
            gaidAllowed = prefs.getBoolean(KEY_GAID, false),
            privacySandboxEnabled = prefs.getBoolean(KEY_PRIVACY_SANDBOX, false),
            timestamp = prefs.getLong(KEY_TIMESTAMP, 0)
        )

    val hasConsent: Boolean
        get() = prefs.getLong(KEY_TIMESTAMP, 0) > 0

    // MARK: - GAID Access

    /**
     * Get GAID if allowed, respecting user's opt-out preference
     */
    suspend fun getAdvertisingId(): String? = withContext(Dispatchers.IO) {
        if (!currentConsent.gaidAllowed) {
            return@withContext null
        }

        try {
            val adInfo = AdvertisingIdClient.getAdvertisingIdInfo(context)

            // Respect system-level opt-out
            if (adInfo.isLimitAdTrackingEnabled) {
                return@withContext null
            }

            adInfo.id
        } catch (e: Exception) {
            android.util.Log.e(TAG, "Failed to get advertising ID", e)
            null
        }
    }

    /**
     * Check if user has opted out of personalized ads at system level
     */
    suspend fun isPersonalizedAdsOptedOut(): Boolean = withContext(Dispatchers.IO) {
        try {
            val adInfo = AdvertisingIdClient.getAdvertisingIdInfo(context)
            adInfo.isLimitAdTrackingEnabled
        } catch (e: Exception) {
            true // Default to opted out on error
        }
    }

    // MARK: - Consent Collection

    /**
     * Show consent dialog and collect user preferences
     */
    fun showConsentDialog(
        activity: AppCompatActivity,
        callback: (ConsentState) -> Unit
    ) {
        val dialog = ConsentDialogFragment.newInstance(
            currentConsent = currentConsent,
            onComplete = { newConsent ->
                saveConsent(newConsent)
                applyConsent(newConsent)
                callback(newConsent)
            }
        )
        dialog.show(activity.supportFragmentManager, "consent_dialog")
    }

    /**
     * Update specific consent purpose
     */
    fun updateConsent(purpose: ConsentPurpose, allowed: Boolean) {
        when (purpose) {
            ConsentPurpose.ANALYTICS ->
                prefs.edit().putBoolean(KEY_ANALYTICS, allowed).apply()
            ConsentPurpose.MARKETING ->
                prefs.edit().putBoolean(KEY_MARKETING, allowed).apply()
            ConsentPurpose.PERSONALIZATION ->
                prefs.edit().putBoolean(KEY_PERSONALIZATION, allowed).apply()
            ConsentPurpose.GAID_ACCESS ->
                prefs.edit().putBoolean(KEY_GAID, allowed).apply()
            ConsentPurpose.PRIVACY_SANDBOX ->
                prefs.edit().putBoolean(KEY_PRIVACY_SANDBOX, allowed).apply()
        }

        prefs.edit().putLong(KEY_TIMESTAMP, System.currentTimeMillis()).apply()
        applyConsent(currentConsent)
    }

    /**
     * Accept all consent options
     */
    fun acceptAll() {
        val consent = ConsentState(
            analyticsAllowed = true,
            marketingAllowed = true,
            personalizationAllowed = true,
            gaidAllowed = true,
            privacySandboxEnabled = true,
            timestamp = System.currentTimeMillis()
        )
        saveConsent(consent)
        applyConsent(consent)
    }

    /**
     * Reject all consent options (essential only)
     */
    fun rejectAll() {
        val consent = ConsentState(
            analyticsAllowed = false,
            marketingAllowed = false,
            personalizationAllowed = false,
            gaidAllowed = false,
            privacySandboxEnabled = false,
            timestamp = System.currentTimeMillis()
        )
        saveConsent(consent)
        applyConsent(consent)
    }

    // MARK: - Private Methods

    private fun saveConsent(consent: ConsentState) {
        prefs.edit().apply {
            putBoolean(KEY_ANALYTICS, consent.analyticsAllowed)
            putBoolean(KEY_MARKETING, consent.marketingAllowed)
            putBoolean(KEY_PERSONALIZATION, consent.personalizationAllowed)
            putBoolean(KEY_GAID, consent.gaidAllowed)
            putBoolean(KEY_PRIVACY_SANDBOX, consent.privacySandboxEnabled)
            putLong(KEY_TIMESTAMP, consent.timestamp)
        }.apply()
    }

    private fun applyConsent(consent: ConsentState) {
        // Configure Firebase Analytics
        configureFirebase(consent)

        // Configure Facebook SDK
        configureFacebook(consent)

        // Configure Privacy Sandbox
        configurePrivacySandbox(consent)

        // Broadcast consent change
        android.content.Intent(ACTION_CONSENT_CHANGED).also { intent ->
            intent.putExtra("analytics", consent.analyticsAllowed)
            intent.putExtra("marketing", consent.marketingAllowed)
            intent.putExtra("personalization", consent.personalizationAllowed)
            context.sendBroadcast(intent)
        }
    }

    private fun configureFirebase(consent: ConsentState) {
        // Configure Firebase Analytics
        try {
            com.google.firebase.analytics.FirebaseAnalytics
                .getInstance(context)
                .setAnalyticsCollectionEnabled(consent.analyticsAllowed)

            // Configure Google Ads consent
            if (consent.marketingAllowed) {
                // Enable ads personalization
            } else {
                // Disable ads personalization
            }
        } catch (e: Exception) {
            android.util.Log.e(TAG, "Failed to configure Firebase", e)
        }
    }

    private fun configureFacebook(consent: ConsentState) {
        try {
            // Configure Facebook SDK
            com.facebook.FacebookSdk.setAutoLogAppEventsEnabled(consent.analyticsAllowed)
            com.facebook.FacebookSdk.setAdvertiserIDCollectionEnabled(consent.gaidAllowed)
        } catch (e: Exception) {
            android.util.Log.e(TAG, "Failed to configure Facebook SDK", e)
        }
    }

    private fun configurePrivacySandbox(consent: ConsentState) {
        // Privacy Sandbox APIs respect their own consent mechanisms
        // Store preference for when APIs are called
    }

    companion object {
        private const val TAG = "AndroidConsentManager"
        private const val PREFS_NAME = "consent_prefs"
        private const val KEY_ANALYTICS = "analytics_allowed"
        private const val KEY_MARKETING = "marketing_allowed"
        private const val KEY_PERSONALIZATION = "personalization_allowed"
        private const val KEY_GAID = "gaid_allowed"
        private const val KEY_PRIVACY_SANDBOX = "privacy_sandbox_enabled"
        private const val KEY_TIMESTAMP = "consent_timestamp"
        const val ACTION_CONSENT_CHANGED = "com.app.CONSENT_CHANGED"
    }
}

/**
 * Consent Dialog Fragment
 */
class ConsentDialogFragment : DialogFragment() {

    private var onComplete: ((AndroidConsentManager.ConsentState) -> Unit)? = null
    private var currentConsent: AndroidConsentManager.ConsentState? = null

    companion object {
        fun newInstance(
            currentConsent: AndroidConsentManager.ConsentState,
            onComplete: (AndroidConsentManager.ConsentState) -> Unit
        ): ConsentDialogFragment {
            return ConsentDialogFragment().apply {
                this.currentConsent = currentConsent
                this.onComplete = onComplete
            }
        }
    }

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val binding = DialogConsentBinding.inflate(layoutInflater)

        // Set current state
        currentConsent?.let { consent ->
            binding.switchAnalytics.isChecked = consent.analyticsAllowed
            binding.switchMarketing.isChecked = consent.marketingAllowed
            binding.switchPersonalization.isChecked = consent.personalizationAllowed
        }

        return AlertDialog.Builder(requireContext())
            .setTitle("Privacy Settings")
            .setView(binding.root)
            .setPositiveButton("Save") { _, _ ->
                val newConsent = AndroidConsentManager.ConsentState(
                    analyticsAllowed = binding.switchAnalytics.isChecked,
                    marketingAllowed = binding.switchMarketing.isChecked,
                    personalizationAllowed = binding.switchPersonalization.isChecked,
                    gaidAllowed = binding.switchMarketing.isChecked,
                    privacySandboxEnabled = binding.switchMarketing.isChecked,
                    timestamp = System.currentTimeMillis()
                )
                onComplete?.invoke(newConsent)
            }
            .setNegativeButton("Cancel", null)
            .setNeutralButton("Reject All") { _, _ ->
                onComplete?.invoke(
                    AndroidConsentManager.ConsentState(
                        analyticsAllowed = false,
                        marketingAllowed = false,
                        personalizationAllowed = false,
                        gaidAllowed = false,
                        privacySandboxEnabled = false,
                        timestamp = System.currentTimeMillis()
                    )
                )
            }
            .create()
    }
}

Cross-Platform CMP SDK Integration

For production apps, use a certified CMP SDK that handles both regulatory consent and platform-specific requirements.

TypeScript React Native Implementation

// MobileConsentManager.ts
import { Platform } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';

// Types
interface ConsentState {
  analytics: boolean;
  marketing: boolean;
  personalization: boolean;
  thirdParty: boolean;
  platformTracking: boolean; // IDFA/GAID
  timestamp: number;
  tcString?: string; // IAB TCF string
  gppString?: string; // Global Privacy Platform string
}

interface ConsentConfig {
  gdprApplies: boolean;
  ccpaApplies: boolean;
  region: string;
  language: string;
}

interface PlatformTrackingStatus {
  platform: 'ios' | 'android';
  status: 'authorized' | 'denied' | 'not_determined' | 'restricted';
  canTrack: boolean;
  identifier?: string;
}

// Native module interfaces (would be implemented natively)
interface NativeATTModule {
  requestAuthorization(): Promise<string>;
  getStatus(): Promise<string>;
  getIDFA(): Promise<string | null>;
}

interface NativeGAIDModule {
  getAdvertisingId(): Promise<string | null>;
  isLimitAdTrackingEnabled(): Promise<boolean>;
}

declare const ATTNativeModule: NativeATTModule;
declare const GAIDNativeModule: NativeGAIDModule;

// Main consent manager
class MobileConsentManager {
  private static instance: MobileConsentManager;
  private consentState: ConsentState | null = null;
  private config: ConsentConfig;
  private listeners: Array<(consent: ConsentState) => void> = [];

  private constructor() {
    this.config = {
      gdprApplies: false,
      ccpaApplies: false,
      region: 'unknown',
      language: 'en'
    };
  }

  static getInstance(): MobileConsentManager {
    if (!MobileConsentManager.instance) {
      MobileConsentManager.instance = new MobileConsentManager();
    }
    return MobileConsentManager.instance;
  }

  // Initialize and load existing consent
  async initialize(config: Partial<ConsentConfig>): Promise<void> {
    this.config = { ...this.config, ...config };

    // Load stored consent
    const stored = await AsyncStorage.getItem('mobile_consent');
    if (stored) {
      this.consentState = JSON.parse(stored);
    }

    // Determine applicable regulations
    await this.detectRegulation();
  }

  private async detectRegulation(): Promise<void> {
    // In production, use IP geolocation or device locale
    const locale = Platform.select({
      ios: await this.getIOSLocale(),
      android: await this.getAndroidLocale(),
      default: 'en-US'
    });

    const euCountries = ['AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE',
                        'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV',
                        'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK',
                        'SI', 'ES', 'SE', 'GB'];

    const countryCode = locale.split('-')[1] || locale;

    this.config.gdprApplies = euCountries.includes(countryCode);
    this.config.ccpaApplies = countryCode === 'US' && locale.includes('CA');
    this.config.region = countryCode;
  }

  private async getIOSLocale(): Promise<string> {
    // Would use native module to get iOS locale
    return 'en-US';
  }

  private async getAndroidLocale(): Promise<string> {
    // Would use native module to get Android locale
    return 'en-US';
  }

  // Get current consent state
  getConsentState(): ConsentState | null {
    return this.consentState;
  }

  // Check if consent is needed
  shouldShowConsentUI(): boolean {
    if (!this.consentState) return true;

    // Check if consent is stale (> 13 months per GDPR)
    const consentAge = Date.now() - this.consentState.timestamp;
    const maxAge = 13 * 30 * 24 * 60 * 60 * 1000; // 13 months

    if (consentAge > maxAge) return true;

    return false;
  }

  // Update consent
  async setConsent(consent: Partial<ConsentState>): Promise<void> {
    this.consentState = {
      analytics: consent.analytics ?? false,
      marketing: consent.marketing ?? false,
      personalization: consent.personalization ?? false,
      thirdParty: consent.thirdParty ?? false,
      platformTracking: consent.platformTracking ?? false,
      timestamp: Date.now(),
      tcString: consent.tcString,
      gppString: consent.gppString
    };

    // Persist
    await AsyncStorage.setItem('mobile_consent', JSON.stringify(this.consentState));

    // Apply to SDKs
    await this.applyConsent();

    // Notify listeners
    this.notifyListeners();
  }

  // Accept all
  async acceptAll(): Promise<void> {
    await this.setConsent({
      analytics: true,
      marketing: true,
      personalization: true,
      thirdParty: true,
      platformTracking: true
    });
  }

  // Reject all
  async rejectAll(): Promise<void> {
    await this.setConsent({
      analytics: false,
      marketing: false,
      personalization: false,
      thirdParty: false,
      platformTracking: false
    });
  }

  // Request platform-specific tracking permission
  async requestPlatformTracking(): Promise<PlatformTrackingStatus> {
    if (Platform.OS === 'ios') {
      return this.requestIOSTracking();
    } else {
      return this.requestAndroidTracking();
    }
  }

  private async requestIOSTracking(): Promise<PlatformTrackingStatus> {
    try {
      const status = await ATTNativeModule.requestAuthorization();
      const idfa = status === 'authorized'
        ? await ATTNativeModule.getIDFA()
        : null;

      return {
        platform: 'ios',
        status: status as PlatformTrackingStatus['status'],
        canTrack: status === 'authorized',
        identifier: idfa ?? undefined
      };
    } catch (error) {
      console.error('ATT request failed:', error);
      return {
        platform: 'ios',
        status: 'denied',
        canTrack: false
      };
    }
  }

  private async requestAndroidTracking(): Promise<PlatformTrackingStatus> {
    try {
      const isLimited = await GAIDNativeModule.isLimitAdTrackingEnabled();

      if (isLimited) {
        return {
          platform: 'android',
          status: 'denied',
          canTrack: false
        };
      }

      const gaid = await GAIDNativeModule.getAdvertisingId();

      return {
        platform: 'android',
        status: gaid ? 'authorized' : 'denied',
        canTrack: !!gaid,
        identifier: gaid ?? undefined
      };
    } catch (error) {
      console.error('GAID request failed:', error);
      return {
        platform: 'android',
        status: 'denied',
        canTrack: false
      };
    }
  }

  // Apply consent to all SDKs
  private async applyConsent(): Promise<void> {
    if (!this.consentState) return;

    // Firebase Analytics
    await this.configureFirebase();

    // Facebook SDK
    await this.configureFacebook();

    // Adjust
    await this.configureAdjust();

    // AppsFlyer
    await this.configureAppsFlyer();

    // Google Ads
    await this.configureGoogleAds();
  }

  private async configureFirebase(): Promise<void> {
    // Would call native Firebase configuration
    console.log('Firebase configured with consent:', this.consentState?.analytics);
  }

  private async configureFacebook(): Promise<void> {
    // Would call native Facebook SDK configuration
    console.log('Facebook SDK configured with consent:', this.consentState?.marketing);
  }

  private async configureAdjust(): Promise<void> {
    // Would call native Adjust configuration
    console.log('Adjust configured with consent:', this.consentState?.marketing);
  }

  private async configureAppsFlyer(): Promise<void> {
    // Would call native AppsFlyer configuration
    console.log('AppsFlyer configured with consent:', this.consentState?.marketing);
  }

  private async configureGoogleAds(): Promise<void> {
    // Would call native Google Ads configuration
    console.log('Google Ads configured with consent:', this.consentState?.marketing);
  }

  // Generate IAB TCF consent string
  generateTCString(): string {
    // Would use IAB TCF encoder
    // This is a simplified example
    if (!this.consentState) return '';

    const purposes = [];
    if (this.consentState.analytics) purposes.push(1, 7);
    if (this.consentState.marketing) purposes.push(2, 3, 4);
    if (this.consentState.personalization) purposes.push(5, 6);

    // In production, use proper TCF encoder
    return `CPXxR...`; // Placeholder
  }

  // Generate GPP string for US privacy
  generateGPPString(): string {
    if (!this.consentState) return '';

    // US Privacy String format: DBAC
    // D = Version (1)
    // B = Notice/Opportunity to opt out (Y/N/-)
    // A = Opt-out sale (Y/N/-)
    // C = LSPA covered (Y/N/-)

    const optOut = !this.consentState.thirdParty;

    return `1${optOut ? 'Y' : 'N'}${optOut ? 'Y' : 'N'}N`;
  }

  // Subscribe to consent changes
  subscribe(listener: (consent: ConsentState) => void): () => void {
    this.listeners.push(listener);

    return () => {
      this.listeners = this.listeners.filter(l => l !== listener);
    };
  }

  private notifyListeners(): void {
    if (!this.consentState) return;

    for (const listener of this.listeners) {
      listener(this.consentState);
    }
  }
}

export default MobileConsentManager;

React Native Consent Banner Component

// ConsentBanner.tsx
import React, { useState, useEffect } from 'react';
import {
  View,
  Text,
  TouchableOpacity,
  StyleSheet,
  Modal,
  Switch,
  ScrollView,
  Platform
} from 'react-native';
import MobileConsentManager from './MobileConsentManager';

interface ConsentBannerProps {
  visible: boolean;
  onComplete: (accepted: boolean) => void;
  config?: {
    primaryColor?: string;
    showCategories?: boolean;
    companyName?: string;
  };
}

const ConsentBanner: React.FC<ConsentBannerProps> = ({
  visible,
  onComplete,
  config = {}
}) => {
  const {
    primaryColor = '#007AFF',
    showCategories = true,
    companyName = 'Our App'
  } = config;

  const [showDetails, setShowDetails] = useState(false);
  const [analytics, setAnalytics] = useState(false);
  const [marketing, setMarketing] = useState(false);
  const [personalization, setPersonalization] = useState(false);

  const consentManager = MobileConsentManager.getInstance();

  const handleAcceptAll = async () => {
    await consentManager.acceptAll();

    // Request platform tracking (ATT on iOS)
    if (Platform.OS === 'ios') {
      await consentManager.requestPlatformTracking();
    }

    onComplete(true);
  };

  const handleRejectAll = async () => {
    await consentManager.rejectAll();
    onComplete(false);
  };

  const handleSavePreferences = async () => {
    await consentManager.setConsent({
      analytics,
      marketing,
      personalization,
      thirdParty: marketing,
      platformTracking: marketing
    });

    // Request platform tracking if marketing accepted
    if (marketing && Platform.OS === 'ios') {
      await consentManager.requestPlatformTracking();
    }

    onComplete(analytics || marketing);
  };

  return (
    <Modal
      visible={visible}
      animationType="slide"
      transparent={true}
    >
      <View style={styles.overlay}>
        <View style={styles.container}>
          <ScrollView>
            <Text style={styles.title}>Your Privacy Matters</Text>

            <Text style={styles.description}>
              {companyName} uses cookies and similar technologies to provide,
              improve, and personalize our services. By tapping "Accept All",
              you consent to our use of these technologies.
            </Text>

            {showDetails && showCategories && (
              <View style={styles.categories}>
                <View style={styles.categoryRow}>
                  <View style={styles.categoryInfo}>
                    <Text style={styles.categoryTitle}>Analytics</Text>
                    <Text style={styles.categoryDescription}>
                      Help us understand how you use our app
                    </Text>
                  </View>
                  <Switch
                    value={analytics}
                    onValueChange={setAnalytics}
                    trackColor={{ true: primaryColor }}
                  />
                </View>

                <View style={styles.categoryRow}>
                  <View style={styles.categoryInfo}>
                    <Text style={styles.categoryTitle}>Marketing</Text>
                    <Text style={styles.categoryDescription}>
                      Show you relevant ads based on your interests
                    </Text>
                  </View>
                  <Switch
                    value={marketing}
                    onValueChange={setMarketing}
                    trackColor={{ true: primaryColor }}
                  />
                </View>

                <View style={styles.categoryRow}>
                  <View style={styles.categoryInfo}>
                    <Text style={styles.categoryTitle}>Personalization</Text>
                    <Text style={styles.categoryDescription}>
                      Customize your experience based on your preferences
                    </Text>
                  </View>
                  <Switch
                    value={personalization}
                    onValueChange={setPersonalization}
                    trackColor={{ true: primaryColor }}
                  />
                </View>
              </View>
            )}

            <View style={styles.buttons}>
              {!showDetails ? (
                <>
                  <TouchableOpacity
                    style={[styles.button, styles.secondaryButton]}
                    onPress={handleRejectAll}
                  >
                    <Text style={styles.secondaryButtonText}>Reject All</Text>
                  </TouchableOpacity>

                  <TouchableOpacity
                    style={[styles.button, styles.secondaryButton]}
                    onPress={() => setShowDetails(true)}
                  >
                    <Text style={styles.secondaryButtonText}>Manage</Text>
                  </TouchableOpacity>

                  <TouchableOpacity
                    style={[styles.button, { backgroundColor: primaryColor }]}
                    onPress={handleAcceptAll}
                  >
                    <Text style={styles.primaryButtonText}>Accept All</Text>
                  </TouchableOpacity>
                </>
              ) : (
                <>
                  <TouchableOpacity
                    style={[styles.button, styles.secondaryButton]}
                    onPress={() => setShowDetails(false)}
                  >
                    <Text style={styles.secondaryButtonText}>Back</Text>
                  </TouchableOpacity>

                  <TouchableOpacity
                    style={[styles.button, { backgroundColor: primaryColor }]}
                    onPress={handleSavePreferences}
                  >
                    <Text style={styles.primaryButtonText}>Save</Text>
                  </TouchableOpacity>
                </>
              )}
            </View>

            <TouchableOpacity style={styles.privacyLink}>
              <Text style={[styles.privacyLinkText, { color: primaryColor }]}>
                Privacy Policy
              </Text>
            </TouchableOpacity>
          </ScrollView>
        </View>
      </View>
    </Modal>
  );
};

const styles = StyleSheet.create({
  overlay: {
    flex: 1,
    backgroundColor: 'rgba(0, 0, 0, 0.5)',
    justifyContent: 'flex-end'
  },
  container: {
    backgroundColor: 'white',
    borderTopLeftRadius: 20,
    borderTopRightRadius: 20,
    padding: 20,
    maxHeight: '80%'
  },
  title: {
    fontSize: 22,
    fontWeight: 'bold',
    marginBottom: 12,
    textAlign: 'center'
  },
  description: {
    fontSize: 14,
    color: '#666',
    lineHeight: 20,
    marginBottom: 20
  },
  categories: {
    marginBottom: 20
  },
  categoryRow: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    paddingVertical: 12,
    borderBottomWidth: 1,
    borderBottomColor: '#eee'
  },
  categoryInfo: {
    flex: 1,
    marginRight: 12
  },
  categoryTitle: {
    fontSize: 16,
    fontWeight: '600',
    marginBottom: 4
  },
  categoryDescription: {
    fontSize: 12,
    color: '#888'
  },
  buttons: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    gap: 10
  },
  button: {
    flex: 1,
    paddingVertical: 14,
    borderRadius: 10,
    alignItems: 'center'
  },
  secondaryButton: {
    backgroundColor: '#f0f0f0'
  },
  primaryButtonText: {
    color: 'white',
    fontWeight: '600',
    fontSize: 16
  },
  secondaryButtonText: {
    color: '#333',
    fontWeight: '600',
    fontSize: 16
  },
  privacyLink: {
    marginTop: 16,
    alignItems: 'center'
  },
  privacyLinkText: {
    fontSize: 14,
    textDecorationLine: 'underline'
  }
});

export default ConsentBanner;

Testing Mobile Consent

Automated Testing Suite

// mobile-consent.test.ts
import { Platform } from 'react-native';
import MobileConsentManager from '../MobileConsentManager';

describe('MobileConsentManager', () => {
  let consentManager: MobileConsentManager;

  beforeEach(async () => {
    consentManager = MobileConsentManager.getInstance();
    await consentManager.initialize({
      gdprApplies: true,
      region: 'DE'
    });
  });

  describe('Initialization', () => {
    it('should initialize with no consent', () => {
      const state = consentManager.getConsentState();
      expect(state).toBeNull();
    });

    it('should detect GDPR applicability for EU regions', async () => {
      await consentManager.initialize({ region: 'DE' });
      expect(consentManager.shouldShowConsentUI()).toBe(true);
    });

    it('should detect CCPA applicability for California', async () => {
      await consentManager.initialize({ region: 'US', ccpaApplies: true });
      expect(consentManager.shouldShowConsentUI()).toBe(true);
    });
  });

  describe('Consent Collection', () => {
    it('should save consent when accepting all', async () => {
      await consentManager.acceptAll();

      const state = consentManager.getConsentState();
      expect(state?.analytics).toBe(true);
      expect(state?.marketing).toBe(true);
      expect(state?.personalization).toBe(true);
      expect(state?.thirdParty).toBe(true);
    });

    it('should save consent when rejecting all', async () => {
      await consentManager.rejectAll();

      const state = consentManager.getConsentState();
      expect(state?.analytics).toBe(false);
      expect(state?.marketing).toBe(false);
      expect(state?.personalization).toBe(false);
      expect(state?.thirdParty).toBe(false);
    });

    it('should support granular consent', async () => {
      await consentManager.setConsent({
        analytics: true,
        marketing: false,
        personalization: true,
        thirdParty: false
      });

      const state = consentManager.getConsentState();
      expect(state?.analytics).toBe(true);
      expect(state?.marketing).toBe(false);
      expect(state?.personalization).toBe(true);
      expect(state?.thirdParty).toBe(false);
    });
  });

  describe('TCF String Generation', () => {
    it('should generate valid TC string when consent granted', async () => {
      await consentManager.acceptAll();

      const tcString = consentManager.generateTCString();
      expect(tcString).toBeTruthy();
      expect(tcString.startsWith('CP')).toBe(true);
    });

    it('should generate GPP string for US privacy', async () => {
      await consentManager.initialize({ region: 'US', ccpaApplies: true });
      await consentManager.acceptAll();

      const gppString = consentManager.generateGPPString();
      expect(gppString).toMatch(/^1[YN][YN][YN]$/);
    });
  });

  describe('Platform Tracking', () => {
    it('should request ATT on iOS', async () => {
      Platform.OS = 'ios';

      const result = await consentManager.requestPlatformTracking();
      expect(result.platform).toBe('ios');
      expect(['authorized', 'denied', 'not_determined', 'restricted'])
        .toContain(result.status);
    });

    it('should check GAID on Android', async () => {
      Platform.OS = 'android';

      const result = await consentManager.requestPlatformTracking();
      expect(result.platform).toBe('android');
      expect(['authorized', 'denied', 'not_determined', 'restricted'])
        .toContain(result.status);
    });
  });

  describe('Consent Expiry', () => {
    it('should require re-consent after 13 months', async () => {
      await consentManager.acceptAll();

      // Mock stale consent
      const staleTimestamp = Date.now() - (14 * 30 * 24 * 60 * 60 * 1000);
      await consentManager.setConsent({
        analytics: true,
        marketing: true,
        timestamp: staleTimestamp
      });

      expect(consentManager.shouldShowConsentUI()).toBe(true);
    });
  });

  describe('Listener Notifications', () => {
    it('should notify listeners on consent change', async () => {
      const listener = jest.fn();
      const unsubscribe = consentManager.subscribe(listener);

      await consentManager.acceptAll();

      expect(listener).toHaveBeenCalledWith(
        expect.objectContaining({
          analytics: true,
          marketing: true
        })
      );

      unsubscribe();
    });
  });
});

Compliance Summary

Mobile Consent Checklist

Requirement iOS Android Notes
ATT/GAID Consent Required Recommended Ask before accessing IDFA/GAID
Pre-permission Screen Recommended Optional Improves opt-in rates
CMP before ATT Best Practice N/A Show CMP first, then ATT
SKAdNetwork Automatic N/A Fallback for attribution
Privacy Sandbox N/A Rolling out Topics API, Attribution Reporting
TCF String If GDPR applies If GDPR applies IAB compliance
GPP String If US privacy If US privacy US state privacy laws
SharedPreferences/UserDefaults Required Required Store consent per IAB spec
SDK Configuration Required Required Configure all SDKs based on consent

Bringing the pieces together

Mobile consent only works when OS-level permissions and regulatory consent strings stay in sync. Apple has rejected apps for triggering ATT after collecting device signals, and Google’s latest Play Console checks flag SDKs that still rely on broad GAID access. The practical takeaway: wire your CMP to your ATT/Privacy Sandbox flows, enforce consent before any network calls, and keep those pathways under automated test coverage.

Key Recommendations

  1. Always show CMP before ATT - Users need context before making tracking decisions.
  2. Design for the 70% opt-out rate - Build your analytics and ROAS models assuming most users will decline tracking.
  3. Use certified CMP SDKs - Production apps should lean on audited SDKs with IAB TCF/GPP support.
  4. Test on real devices - Emulators rarely simulate ATT timing or Privacy Sandbox APIs accurately.
  5. Prepare for Privacy Sandbox - GAID deprecation testing expanded in 2024; plan to ship Topics/Attribution Reporting integrations early.
  6. Keep consent synchronized - CMP decisions must propagate to SDK initialization, server-side events, and data warehouses.

The Future of Mobile Privacy

Both Apple and Google are moving toward a privacy-first future. The apps that thrive will be those that embrace privacy-preserving measurement and advertising technologies rather than fighting against them. Invest in first-party data strategies, server-side tracking, and privacy-preserving attribution to future-proof your mobile analytics.

By following this guide, you'll have mobile consent implementation that respects user privacy, complies with regulations, and maintains the measurement capabilities needed for growth.

Preguntas frecuentes

Can I show the ATT prompt after the user uses the app for a while?
Yes, and it is often recommended. Asking for tracking permission at a "high context" moment (e.g., when they want to support a creator) can improve opt-in rates compared to asking immediately at launch.
G

Equipo de GetCookies

Escrito y mantenido por el equipo de GetCookies en Getia AS, en Oslo (Noruega). Este artículo es información general sobre el consentimiento de cookies, no asesoramiento jurídico.

Escanea tu propio sitio gratis

El plan gratuito muestra el banner en un sitio con tres escaneos de cookies al mes. Sin tarjeta, sin llamadas comerciales.