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.