From d26fa41b06e385a4ca2f8e6405074fc50e0da3c5 Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Tue, 12 May 2026 11:42:05 +1200 Subject: [PATCH] feat(audience-sdk): ship iOS PrivacyInfo.xcprivacy privacy manifest (SDK-315) Co-Authored-By: Claude Sonnet 4.6 --- .../Editor/iOSPrivacyManifestPostProcessor.cs | 113 ++++++++++++++++++ .../iOSPrivacyManifestPostProcessor.cs.meta | 11 ++ .../iOS/PrivacyInfo.attribution.xcprivacy | 42 +++++++ .../PrivacyInfo.attribution.xcprivacy.meta | 32 +++++ .../Runtime/Plugins/iOS/PrivacyInfo.xcprivacy | 29 +++++ .../Plugins/iOS/PrivacyInfo.xcprivacy.meta | 32 +++++ 6 files changed, 259 insertions(+) create mode 100644 src/Packages/Audience/Editor/iOSPrivacyManifestPostProcessor.cs create mode 100644 src/Packages/Audience/Editor/iOSPrivacyManifestPostProcessor.cs.meta create mode 100644 src/Packages/Audience/Runtime/Plugins/iOS/PrivacyInfo.attribution.xcprivacy create mode 100644 src/Packages/Audience/Runtime/Plugins/iOS/PrivacyInfo.attribution.xcprivacy.meta create mode 100644 src/Packages/Audience/Runtime/Plugins/iOS/PrivacyInfo.xcprivacy create mode 100644 src/Packages/Audience/Runtime/Plugins/iOS/PrivacyInfo.xcprivacy.meta diff --git a/src/Packages/Audience/Editor/iOSPrivacyManifestPostProcessor.cs b/src/Packages/Audience/Editor/iOSPrivacyManifestPostProcessor.cs new file mode 100644 index 000000000..2f39b2a10 --- /dev/null +++ b/src/Packages/Audience/Editor/iOSPrivacyManifestPostProcessor.cs @@ -0,0 +1,113 @@ +#nullable enable + +using System.IO; +using UnityEditor; +using UnityEditor.Callbacks; +using UnityEngine; +#if UNITY_IOS +using UnityEditor.iOS.Xcode; +#endif + +namespace Immutable.Audience.Editor +{ + /// + /// Patches UnityFramework/PrivacyInfo.xcprivacy to add IDFA tracking + /// declarations when AUDIENCE_MOBILE_ATTRIBUTION is enabled. + /// Unity auto-merges the default manifest; this post-processor adds + /// NSPrivacyTracking=true and NSPrivacyCollectedDataTypeAdvertisingData + /// in-place so Unity's own Required Reason API entries are preserved. + /// Runs at callbackOrder 9052, after the Info.plist (9050) and framework (9051) post-processors. + /// + internal static class iOSPrivacyManifestPostProcessor + { + internal const int CallbackOrder = 9052; + private const string BuiltManifestName = "PrivacyInfo.xcprivacy"; + + [PostProcessBuild(CallbackOrder)] + internal static void OnPostProcessBuild(BuildTarget target, string pathToBuiltProject) + { + if (target != BuildTarget.iOS) return; + +#if UNITY_IOS + if (!AttributionDefineEnabled()) return; + + var builtManifestPath = FindBuiltManifest(pathToBuiltProject); + if (builtManifestPath == null) + { + Debug.LogWarning( + $"[ImmutableAudience] iOS privacy manifest post-processor: {BuiltManifestName} not found " + + $"under {pathToBuiltProject}. Skipping attribution manifest update."); + return; + } + + var plist = new PlistDocument(); + plist.ReadFromFile(builtManifestPath); + ApplyAttributionPrivacyEntries(plist.root); + plist.WriteToFile(builtManifestPath); +#endif + } + + private static string? FindBuiltManifest(string pathToBuiltProject) + { + // Unity 2019.3+ places the merged manifest here. + var candidate = Path.Combine(pathToBuiltProject, "UnityFramework", BuiltManifestName); + if (File.Exists(candidate)) return candidate; + + // Fall back to a recursive search for older Unity layout variants. + var found = Directory.GetFiles(pathToBuiltProject, BuiltManifestName, SearchOption.AllDirectories); + return found.Length > 0 ? found[0] : null; + } + +#if UNITY_IOS + /// + /// Adds the attribution-specific privacy declarations to an existing + /// (already Unity-merged) PrivacyInfo.xcprivacy plist root. + /// Idempotent — safe to call on a manifest that already has these entries. + /// + internal static void ApplyAttributionPrivacyEntries(PlistElementDict root) + { + // IDFA collection constitutes tracking under Apple's definition. + root.SetBoolean("NSPrivacyTracking", true); + + PlistElementArray dataTypes; + if (root.values.TryGetValue("NSPrivacyCollectedDataTypes", out var existing) && + existing is PlistElementArray existingArray) + { + dataTypes = existingArray; + } + else + { + dataTypes = root.CreateArray("NSPrivacyCollectedDataTypes"); + } + + // Avoid duplicate entries if the post-processor is re-run. + const string advertisingType = "NSPrivacyCollectedDataTypeAdvertisingData"; + foreach (var item in dataTypes.values) + { + if (item is PlistElementDict d && + d.values.TryGetValue("NSPrivacyCollectedDataType", out var v) && + v is PlistElementString s && + s.value == advertisingType) + return; + } + + var entry = dataTypes.AddDict(); + entry.SetString("NSPrivacyCollectedDataType", advertisingType); + entry.SetBoolean("NSPrivacyCollectedDataTypeLinked", true); + entry.SetBoolean("NSPrivacyCollectedDataTypeTracking", true); + var purposes = entry.CreateArray("NSPrivacyCollectedDataTypePurposes"); + purposes.AddString("NSPrivacyCollectedDataTypePurposeAnalytics"); + } +#endif + + private static bool AttributionDefineEnabled() + { + var defines = PlayerSettings.GetScriptingDefineSymbolsForGroup(BuildTargetGroup.iOS) ?? string.Empty; + foreach (var define in defines.Split(';')) + { + if (define.Trim() == iOSInfoPlistPostProcessor.AttributionDefine) return true; + } + return false; + } + } +} diff --git a/src/Packages/Audience/Editor/iOSPrivacyManifestPostProcessor.cs.meta b/src/Packages/Audience/Editor/iOSPrivacyManifestPostProcessor.cs.meta new file mode 100644 index 000000000..e10ef7d03 --- /dev/null +++ b/src/Packages/Audience/Editor/iOSPrivacyManifestPostProcessor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8e0d5553e0d6341f89ee2546581a1632 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Runtime/Plugins/iOS/PrivacyInfo.attribution.xcprivacy b/src/Packages/Audience/Runtime/Plugins/iOS/PrivacyInfo.attribution.xcprivacy new file mode 100644 index 000000000..72d54c0cc --- /dev/null +++ b/src/Packages/Audience/Runtime/Plugins/iOS/PrivacyInfo.attribution.xcprivacy @@ -0,0 +1,42 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeDeviceID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeAdvertisingData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + + NSPrivacyAccessedAPITypes + + + diff --git a/src/Packages/Audience/Runtime/Plugins/iOS/PrivacyInfo.attribution.xcprivacy.meta b/src/Packages/Audience/Runtime/Plugins/iOS/PrivacyInfo.attribution.xcprivacy.meta new file mode 100644 index 000000000..c90f0aef1 --- /dev/null +++ b/src/Packages/Audience/Runtime/Plugins/iOS/PrivacyInfo.attribution.xcprivacy.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + iPhone: iOS + second: + enabled: 0 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Runtime/Plugins/iOS/PrivacyInfo.xcprivacy b/src/Packages/Audience/Runtime/Plugins/iOS/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..d469414b1 --- /dev/null +++ b/src/Packages/Audience/Runtime/Plugins/iOS/PrivacyInfo.xcprivacy @@ -0,0 +1,29 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeDeviceID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + + NSPrivacyAccessedAPITypes + + + diff --git a/src/Packages/Audience/Runtime/Plugins/iOS/PrivacyInfo.xcprivacy.meta b/src/Packages/Audience/Runtime/Plugins/iOS/PrivacyInfo.xcprivacy.meta new file mode 100644 index 000000000..450acf685 --- /dev/null +++ b/src/Packages/Audience/Runtime/Plugins/iOS/PrivacyInfo.xcprivacy.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + iPhone: iOS + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: