Retour au blog
Technical

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

David Kim, WordPress DeveloperNovember 16, 202516 min de lecture
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 ```swift // 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 ```swift // 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 ```kotlin // 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, 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 ```kotlin // 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 ```typescript // 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; getStatus(): Promise; getIDFA(): Promise; } interface NativeGAIDModule { getAdvertisingId(): Promise; isLimitAdTrackingEnabled(): Promise; } 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): Promise { 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 { // 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 { // Would use native module to get iOS locale return 'en-US'; } private async getAndroidLocale(): Promise { // 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): Promise { 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 { await this.setConsent({ analytics: true, marketing: true, personalization: true, thirdParty: true, platformTracking: true }); } // Reject all async rejectAll(): Promise { await this.setConsent({ analytics: false, marketing: false, personalization: false, thirdParty: false, platformTracking: false }); } // Request platform-specific tracking permission async requestPlatformTracking(): Promise { if (Platform.OS === 'ios') { return this.requestIOSTracking(); } else { return this.requestAndroidTracking(); } } private async requestIOSTracking(): Promise { 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 { 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 { 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 { // Would call native Firebase configuration console.log('Firebase configured with consent:', this.consentState?.analytics); } private async configureFacebook(): Promise { // Would call native Facebook SDK configuration console.log('Facebook SDK configured with consent:', this.consentState?.marketing); } private async configureAdjust(): Promise { // Would call native Adjust configuration console.log('Adjust configured with consent:', this.consentState?.marketing); } private async configureAppsFlyer(): Promise { // Would call native AppsFlyer configuration console.log('AppsFlyer configured with consent:', this.consentState?.marketing); } private async configureGoogleAds(): Promise { // 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 ```tsx // 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 = ({ 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 ( ); }; 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 ```typescript // 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.

Questions fréquentes

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.
D

David Kim, WordPress Developer

Rédacteur chez GetCookies, spécialisé dans la conformité en matière de confidentialité, la gestion du consentement et l'optimisation du marketing numérique.

Prêt à simplifier le consentement cookies ?

GetCookies rend la conformité RGPD, CCPA et mondiale sans effort. Commencez aujourd'hui.