From a56fea7e260ded1bdaa4aaf13b7638149102c506 Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Thu, 7 May 2026 11:14:38 +1200 Subject: [PATCH] feat(audience-sdk): iOS Info.plist post-processor for ATT + SKAdNetwork (SDK-308) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Packages/Audience/Editor/AssemblyInfo.cs | 3 + .../Audience/Editor/AssemblyInfo.cs.meta | 11 + .../Editor/AudienceMobileBuildSettings.cs | 73 +++++++ .../AudienceMobileBuildSettings.cs.meta | 11 + .../Editor/iOSInfoPlistPostProcessor.cs | 167 +++++++++++++++ .../Editor/iOSInfoPlistPostProcessor.cs.meta | 11 + src/Packages/Audience/Tests/Editor.meta | 8 + ...com.immutable.audience.editor.tests.asmdef | 19 ++ ...mmutable.audience.editor.tests.asmdef.meta | 7 + .../Editor/iOSInfoPlistPostProcessorTests.cs | 202 ++++++++++++++++++ .../iOSInfoPlistPostProcessorTests.cs.meta | 11 + 11 files changed, 523 insertions(+) create mode 100644 src/Packages/Audience/Editor/AssemblyInfo.cs create mode 100644 src/Packages/Audience/Editor/AssemblyInfo.cs.meta create mode 100644 src/Packages/Audience/Editor/AudienceMobileBuildSettings.cs create mode 100644 src/Packages/Audience/Editor/AudienceMobileBuildSettings.cs.meta create mode 100644 src/Packages/Audience/Editor/iOSInfoPlistPostProcessor.cs create mode 100644 src/Packages/Audience/Editor/iOSInfoPlistPostProcessor.cs.meta create mode 100644 src/Packages/Audience/Tests/Editor.meta create mode 100644 src/Packages/Audience/Tests/Editor/com.immutable.audience.editor.tests.asmdef create mode 100644 src/Packages/Audience/Tests/Editor/com.immutable.audience.editor.tests.asmdef.meta create mode 100644 src/Packages/Audience/Tests/Editor/iOSInfoPlistPostProcessorTests.cs create mode 100644 src/Packages/Audience/Tests/Editor/iOSInfoPlistPostProcessorTests.cs.meta diff --git a/src/Packages/Audience/Editor/AssemblyInfo.cs b/src/Packages/Audience/Editor/AssemblyInfo.cs new file mode 100644 index 00000000..5e3885e6 --- /dev/null +++ b/src/Packages/Audience/Editor/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Immutable.Audience.Editor.Tests")] diff --git a/src/Packages/Audience/Editor/AssemblyInfo.cs.meta b/src/Packages/Audience/Editor/AssemblyInfo.cs.meta new file mode 100644 index 00000000..0326b53c --- /dev/null +++ b/src/Packages/Audience/Editor/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Editor/AudienceMobileBuildSettings.cs b/src/Packages/Audience/Editor/AudienceMobileBuildSettings.cs new file mode 100644 index 00000000..41a367f8 --- /dev/null +++ b/src/Packages/Audience/Editor/AudienceMobileBuildSettings.cs @@ -0,0 +1,73 @@ +#nullable enable + +using UnityEditor; +using UnityEngine; + +namespace Immutable.Audience.Editor +{ + /// + /// Build-time iOS settings injected into the generated Xcode project's + /// Info.plist by . + /// + /// + /// The runtime AudienceConfig can't be read at build time, so the + /// iOS post-processor needs an asset-backed source of truth for values + /// that must land in Info.plist before the binary is signed. The + /// post-processor finds the asset by type, so studios can keep it + /// wherever fits their project layout. + /// + public sealed class AudienceMobileBuildSettings : ScriptableObject + { + // Fallback so a build never ships with the key missing (which would + // block App Store submission). Studios should override on the asset + // with copy describing what is collected and why. + internal const string DefaultTrackingUsageDescription = + "Your data may be used to deliver personalised ads to you. " + + "You can change this preference at any time in Settings."; + + [SerializeField] + [Tooltip("Copy shown in the iOS App Tracking Transparency prompt. " + + "Apple rejects empty or generic strings — describe what is " + + "collected and why.")] + private string trackingUsageDescription = DefaultTrackingUsageDescription; + + [SerializeField] + [Tooltip("SKAdNetwork IDs (e.g. \"abc123.skadnetwork\") to register " + + "with the App Store as supported ad networks. Provided by " + + "your ad partners.")] + private string[] skAdNetworkIds = new string[0]; + + public string TrackingUsageDescription => + string.IsNullOrWhiteSpace(trackingUsageDescription) + ? DefaultTrackingUsageDescription + : trackingUsageDescription; + + public string[] SKAdNetworkIds => skAdNetworkIds ?? new string[0]; + + [MenuItem("Assets/Create/Immutable Audience/Mobile Build Settings", priority = 100)] + private static void CreateAsset() + { + var asset = CreateInstance(); + ProjectWindowUtil.CreateAsset(asset, "AudienceMobileBuildSettings.asset"); + } + + /// + /// Locates the first asset under Assets/, or null if + /// none exists. + /// + internal static AudienceMobileBuildSettings? FindAsset() + { + var guids = AssetDatabase.FindAssets($"t:{nameof(AudienceMobileBuildSettings)}"); + if (guids.Length == 0) return null; + + var path = AssetDatabase.GUIDToAssetPath(guids[0]); + if (guids.Length > 1) + { + Debug.LogWarning( + $"[ImmutableAudience] Multiple AudienceMobileBuildSettings assets found — " + + $"using '{path}'. Remove the duplicates to avoid unexpected build behaviour."); + } + return AssetDatabase.LoadAssetAtPath(path); + } + } +} diff --git a/src/Packages/Audience/Editor/AudienceMobileBuildSettings.cs.meta b/src/Packages/Audience/Editor/AudienceMobileBuildSettings.cs.meta new file mode 100644 index 00000000..b33d94cd --- /dev/null +++ b/src/Packages/Audience/Editor/AudienceMobileBuildSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Editor/iOSInfoPlistPostProcessor.cs b/src/Packages/Audience/Editor/iOSInfoPlistPostProcessor.cs new file mode 100644 index 00000000..edd5d644 --- /dev/null +++ b/src/Packages/Audience/Editor/iOSInfoPlistPostProcessor.cs @@ -0,0 +1,167 @@ +#nullable enable + +using System.IO; +using UnityEditor; +using UnityEditor.Callbacks; +using UnityEngine; +#if UNITY_IOS +using UnityEditor.iOS.Xcode; +#endif + +namespace Immutable.Audience.Editor +{ + /// + /// Injects mobile-attribution keys into the generated iOS Xcode project's + /// Info.plist: NSUserTrackingUsageDescription (the ATT + /// prompt copy) and SKAdNetworkItems. + /// + /// + /// Both keys are gated by the AUDIENCE_MOBILE_ATTRIBUTION + /// scripting define so a studio that hasn't opted into attribution + /// ships a clean Info.plist — Apple flags apps that include + /// either key without the corresponding code paths. + /// + /// Values come from the + /// asset. If the asset is missing, a default + /// NSUserTrackingUsageDescription is still written (Apple + /// rejects builds with the key missing) but no SKAdNetworkItems. + /// + /// callbackOrder = 9050 runs above Unity's own post-processors + /// (order 1) so studio post-processors with low orders run first, + /// while higher-order post-processors that extend + /// SKAdNetworkItems can still merge their entries on top. + /// + internal static class iOSInfoPlistPostProcessor + { + internal const int CallbackOrder = 9050; + internal const string AttributionDefine = "AUDIENCE_MOBILE_ATTRIBUTION"; + + [PostProcessBuild(CallbackOrder)] + internal static void OnPostProcessBuild(BuildTarget target, string pathToBuiltProject) + { + if (target != BuildTarget.iOS) return; + +#if UNITY_IOS + if (!AttributionDefineEnabled()) return; + + var plistPath = Path.Combine(pathToBuiltProject, "Info.plist"); + if (!File.Exists(plistPath)) + { + Debug.LogWarning( + $"[ImmutableAudience] iOS post-processor: Info.plist not found at {plistPath}. Skipping."); + return; + } + + var settings = AudienceMobileBuildSettings.FindAsset(); + + var plist = new PlistDocument(); + plist.ReadFromFile(plistPath); + + ApplyTrackingUsageDescription(plist.root, settings); + ApplySKAdNetworkItems(plist.root, settings); + + plist.WriteToFile(plistPath); +#endif + } + + // Sanity-check the settings asset without running a full iOS build. + [MenuItem("Tools/Immutable/Audience/Validate iOS Build Settings")] + private static void ValidateBuildSettings() + { + if (!AttributionDefineEnabled()) + { + Debug.LogWarning( + "[ImmutableAudience] AUDIENCE_MOBILE_ATTRIBUTION scripting define is not set " + + "for the iOS player target. The post-processor will not modify Info.plist. " + + "Add the define under Player Settings → Other Settings → Scripting Define Symbols."); + return; + } + + var settings = AudienceMobileBuildSettings.FindAsset(); + var description = settings != null + ? settings.TrackingUsageDescription + : AudienceMobileBuildSettings.DefaultTrackingUsageDescription; + var ids = settings?.SKAdNetworkIds ?? new string[0]; + + Debug.Log( + "[ImmutableAudience] iOS Info.plist injection preview\n" + + $" NSUserTrackingUsageDescription: {description}\n" + + $" SKAdNetworkItems: {ids.Length} id(s)\n" + + (ids.Length == 0 + ? " (no SKAdNetwork ids configured — set them on the AudienceMobileBuildSettings asset)\n" + : string.Concat(System.Array.ConvertAll(ids, id => $" - {id}\n")))); + } + + // Reads the iOS-target define list specifically — the post-processor + // mutates iOS build output regardless of which target the editor is + // currently focused on. + private static bool AttributionDefineEnabled() + { + var defines = PlayerSettings.GetScriptingDefineSymbolsForGroup(BuildTargetGroup.iOS) ?? string.Empty; + foreach (var define in defines.Split(';')) + { + if (define.Trim() == AttributionDefine) return true; + } + return false; + } + +#if UNITY_IOS + internal static void ApplyTrackingUsageDescription( + PlistElementDict root, + AudienceMobileBuildSettings? settings) + { + var description = settings != null + ? settings.TrackingUsageDescription + : AudienceMobileBuildSettings.DefaultTrackingUsageDescription; + + // Always overwrite — the settings asset is the source of truth, + // beating any placeholder a lower-order post-processor wrote. + root.SetString("NSUserTrackingUsageDescription", description); + } + + internal static void ApplySKAdNetworkItems( + PlistElementDict root, + AudienceMobileBuildSettings? settings) + { + var ids = settings?.SKAdNetworkIds ?? new string[0]; + if (ids.Length == 0) return; + + // Merge with any existing list so a lower-order post-processor's + // entries aren't clobbered. Dedup is case-insensitive per Apple's + // SKAdNetwork spec. + PlistElementArray array; + if (root.values.TryGetValue("SKAdNetworkItems", out var existing) && + existing is PlistElementArray existingArray) + { + array = existingArray; + } + else + { + array = root.CreateArray("SKAdNetworkItems"); + } + + var existingIds = new System.Collections.Generic.HashSet( + System.StringComparer.OrdinalIgnoreCase); + foreach (var item in array.values) + { + if (item is PlistElementDict dict && + dict.values.TryGetValue("SKAdNetworkIdentifier", out var idValue) && + idValue is PlistElementString idString) + { + existingIds.Add(idString.value); + } + } + + foreach (var id in ids) + { + if (string.IsNullOrWhiteSpace(id)) continue; + if (existingIds.Contains(id)) continue; + + var dict = array.AddDict(); + dict.SetString("SKAdNetworkIdentifier", id); + existingIds.Add(id); + } + } +#endif + } +} diff --git a/src/Packages/Audience/Editor/iOSInfoPlistPostProcessor.cs.meta b/src/Packages/Audience/Editor/iOSInfoPlistPostProcessor.cs.meta new file mode 100644 index 00000000..dfd34256 --- /dev/null +++ b/src/Packages/Audience/Editor/iOSInfoPlistPostProcessor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Tests/Editor.meta b/src/Packages/Audience/Tests/Editor.meta new file mode 100644 index 00000000..c4481a26 --- /dev/null +++ b/src/Packages/Audience/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Tests/Editor/com.immutable.audience.editor.tests.asmdef b/src/Packages/Audience/Tests/Editor/com.immutable.audience.editor.tests.asmdef new file mode 100644 index 00000000..c489db31 --- /dev/null +++ b/src/Packages/Audience/Tests/Editor/com.immutable.audience.editor.tests.asmdef @@ -0,0 +1,19 @@ +{ + "name": "Immutable.Audience.Editor.Tests", + "rootNamespace": "Immutable.Audience.Editor.Tests", + "references": [ + "Immutable.Audience.Runtime", + "Immutable.Audience.Editor", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": ["Editor"], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": ["nunit.framework.dll"], + "autoReferenced": false, + "defineConstraints": ["UNITY_INCLUDE_TESTS"], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/src/Packages/Audience/Tests/Editor/com.immutable.audience.editor.tests.asmdef.meta b/src/Packages/Audience/Tests/Editor/com.immutable.audience.editor.tests.asmdef.meta new file mode 100644 index 00000000..990f9f96 --- /dev/null +++ b/src/Packages/Audience/Tests/Editor/com.immutable.audience.editor.tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Tests/Editor/iOSInfoPlistPostProcessorTests.cs b/src/Packages/Audience/Tests/Editor/iOSInfoPlistPostProcessorTests.cs new file mode 100644 index 00000000..da086563 --- /dev/null +++ b/src/Packages/Audience/Tests/Editor/iOSInfoPlistPostProcessorTests.cs @@ -0,0 +1,202 @@ +#nullable enable + +#if UNITY_IOS +using NUnit.Framework; +using UnityEditor.iOS.Xcode; +using UnityEngine; +using Immutable.Audience.Editor; + +namespace Immutable.Audience.Editor.Tests +{ + [TestFixture] + internal class iOSInfoPlistPostProcessorTests + { + // ----------------------------------------------------------------- + // ApplyTrackingUsageDescription + // ----------------------------------------------------------------- + + [Test] + public void ApplyTrackingUsageDescription_NoSettings_WritesDefault() + { + // Safe-ship guarantee: Apple rejects builds with the key missing + // or empty, so a default is always written. + var root = new PlistDocument().root; + + iOSInfoPlistPostProcessor.ApplyTrackingUsageDescription(root, settings: null); + + var description = ReadString(root, "NSUserTrackingUsageDescription"); + Assert.AreEqual(AudienceMobileBuildSettings.DefaultTrackingUsageDescription, description); + } + + [Test] + public void ApplyTrackingUsageDescription_WithCustomCopy_WritesIt() + { + var settings = ScriptableObject.CreateInstance(); + SetPrivate(settings, "trackingUsageDescription", "Custom prompt copy"); + + var root = new PlistDocument().root; + + iOSInfoPlistPostProcessor.ApplyTrackingUsageDescription(root, settings); + + Assert.AreEqual("Custom prompt copy", ReadString(root, "NSUserTrackingUsageDescription")); + Object.DestroyImmediate(settings); + } + + [Test] + public void ApplyTrackingUsageDescription_WithEmptyCopy_FallsBackToDefault() + { + // Whitespace falls through to the default — Apple rejects empty. + var settings = ScriptableObject.CreateInstance(); + SetPrivate(settings, "trackingUsageDescription", " "); + + var root = new PlistDocument().root; + + iOSInfoPlistPostProcessor.ApplyTrackingUsageDescription(root, settings); + + Assert.AreEqual(AudienceMobileBuildSettings.DefaultTrackingUsageDescription, + ReadString(root, "NSUserTrackingUsageDescription")); + Object.DestroyImmediate(settings); + } + + [Test] + public void ApplyTrackingUsageDescription_OverwritesExistingValue() + { + // Settings asset is the source of truth — beat any placeholder + // a lower-order post-processor wrote. + var root = new PlistDocument().root; + root.SetString("NSUserTrackingUsageDescription", "stale placeholder"); + + var settings = ScriptableObject.CreateInstance(); + SetPrivate(settings, "trackingUsageDescription", "fresh copy"); + + iOSInfoPlistPostProcessor.ApplyTrackingUsageDescription(root, settings); + + Assert.AreEqual("fresh copy", ReadString(root, "NSUserTrackingUsageDescription")); + Object.DestroyImmediate(settings); + } + + // ----------------------------------------------------------------- + // ApplySKAdNetworkItems + // ----------------------------------------------------------------- + + [Test] + public void ApplySKAdNetworkItems_NoSettings_LeavesPlistUntouched() + { + var root = new PlistDocument().root; + + iOSInfoPlistPostProcessor.ApplySKAdNetworkItems(root, settings: null); + + Assert.IsFalse(root.values.ContainsKey("SKAdNetworkItems"), + "No settings asset → no SKAdNetworkItems key should be written"); + } + + [Test] + public void ApplySKAdNetworkItems_EmptyIds_LeavesPlistUntouched() + { + var settings = ScriptableObject.CreateInstance(); + SetPrivate(settings, "skAdNetworkIds", new string[0]); + + var root = new PlistDocument().root; + + iOSInfoPlistPostProcessor.ApplySKAdNetworkItems(root, settings); + + Assert.IsFalse(root.values.ContainsKey("SKAdNetworkItems"), + "Empty ID array → no SKAdNetworkItems key should be written"); + Object.DestroyImmediate(settings); + } + + [Test] + public void ApplySKAdNetworkItems_WithIds_WritesArrayOfDicts() + { + var settings = ScriptableObject.CreateInstance(); + SetPrivate(settings, "skAdNetworkIds", new[] { "abc123.skadnetwork", "def456.skadnetwork" }); + + var root = new PlistDocument().root; + + iOSInfoPlistPostProcessor.ApplySKAdNetworkItems(root, settings); + + var array = (PlistElementArray)root.values["SKAdNetworkItems"]; + Assert.AreEqual(2, array.values.Count); + + var first = (PlistElementDict)array.values[0]; + Assert.AreEqual("abc123.skadnetwork", ((PlistElementString)first.values["SKAdNetworkIdentifier"]).value); + + var second = (PlistElementDict)array.values[1]; + Assert.AreEqual("def456.skadnetwork", ((PlistElementString)second.values["SKAdNetworkIdentifier"]).value); + Object.DestroyImmediate(settings); + } + + [Test] + public void ApplySKAdNetworkItems_MergesWithExistingArray() + { + // Preserve entries written by lower-order post-processors. + var root = new PlistDocument().root; + var existing = root.CreateArray("SKAdNetworkItems"); + existing.AddDict().SetString("SKAdNetworkIdentifier", "existing.skadnetwork"); + + var settings = ScriptableObject.CreateInstance(); + SetPrivate(settings, "skAdNetworkIds", new[] { "added.skadnetwork" }); + + iOSInfoPlistPostProcessor.ApplySKAdNetworkItems(root, settings); + + var array = (PlistElementArray)root.values["SKAdNetworkItems"]; + Assert.AreEqual(2, array.values.Count); + Object.DestroyImmediate(settings); + } + + [Test] + public void ApplySKAdNetworkItems_DedupesCaseInsensitive() + { + // Apple's registry is case-insensitive; dupes fail validation. + var root = new PlistDocument().root; + var existing = root.CreateArray("SKAdNetworkItems"); + existing.AddDict().SetString("SKAdNetworkIdentifier", "ABC123.skadnetwork"); + + var settings = ScriptableObject.CreateInstance(); + SetPrivate(settings, "skAdNetworkIds", + new[] { "abc123.skadnetwork", "DEF456.skadnetwork" }); + + iOSInfoPlistPostProcessor.ApplySKAdNetworkItems(root, settings); + + var array = (PlistElementArray)root.values["SKAdNetworkItems"]; + Assert.AreEqual(2, array.values.Count, + "abc123 already present (different case) should not be added a second time"); + Object.DestroyImmediate(settings); + } + + [Test] + public void ApplySKAdNetworkItems_SkipsNullOrWhitespaceIds() + { + // Blank inspector rows shouldn't produce empty identifier dicts. + var settings = ScriptableObject.CreateInstance(); + SetPrivate(settings, "skAdNetworkIds", + new[] { "abc123.skadnetwork", " ", null!, "def456.skadnetwork" }); + + var root = new PlistDocument().root; + + iOSInfoPlistPostProcessor.ApplySKAdNetworkItems(root, settings); + + var array = (PlistElementArray)root.values["SKAdNetworkItems"]; + Assert.AreEqual(2, array.values.Count); + Object.DestroyImmediate(settings); + } + + // ----------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------- + + private static string ReadString(PlistElementDict dict, string key) => + ((PlistElementString)dict.values[key]).value; + + // [SerializeField] private fields aren't reachable from tests. + private static void SetPrivate(object target, string fieldName, object? value) + { + var field = target.GetType().GetField(fieldName, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic); + Assert.IsNotNull(field, $"Field '{fieldName}' not found on {target.GetType()}"); + field!.SetValue(target, value); + } + } +} +#endif diff --git a/src/Packages/Audience/Tests/Editor/iOSInfoPlistPostProcessorTests.cs.meta b/src/Packages/Audience/Tests/Editor/iOSInfoPlistPostProcessorTests.cs.meta new file mode 100644 index 00000000..b42a831f --- /dev/null +++ b/src/Packages/Audience/Tests/Editor/iOSInfoPlistPostProcessorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: