|
| 1 | +#nullable enable |
| 2 | + |
| 3 | +using System.IO; |
| 4 | +using UnityEditor; |
| 5 | +using UnityEditor.Callbacks; |
| 6 | +using UnityEngine; |
| 7 | +#if UNITY_IOS |
| 8 | +using UnityEditor.iOS.Xcode; |
| 9 | +#endif |
| 10 | + |
| 11 | +namespace Immutable.Audience.Editor |
| 12 | +{ |
| 13 | + /// <summary> |
| 14 | + /// Injects mobile-attribution keys into the generated iOS Xcode project's |
| 15 | + /// <c>Info.plist</c>: <c>NSUserTrackingUsageDescription</c> (the ATT |
| 16 | + /// prompt copy) and <c>SKAdNetworkItems</c>. |
| 17 | + /// </summary> |
| 18 | + /// <remarks> |
| 19 | + /// Both keys are gated by the <c>AUDIENCE_MOBILE_ATTRIBUTION</c> |
| 20 | + /// scripting define so a studio that hasn't opted into attribution |
| 21 | + /// ships a clean <c>Info.plist</c> — Apple flags apps that include |
| 22 | + /// either key without the corresponding code paths. |
| 23 | + /// |
| 24 | + /// Values come from the <see cref="AudienceMobileBuildSettings"/> |
| 25 | + /// asset. If the asset is missing, a default |
| 26 | + /// <c>NSUserTrackingUsageDescription</c> is still written (Apple |
| 27 | + /// rejects builds with the key missing) but no <c>SKAdNetworkItems</c>. |
| 28 | + /// |
| 29 | + /// <c>callbackOrder = 9050</c> runs above Unity's own post-processors |
| 30 | + /// (order 1) so studio post-processors with low orders run first, |
| 31 | + /// while higher-order post-processors that extend |
| 32 | + /// <c>SKAdNetworkItems</c> can still merge their entries on top. |
| 33 | + /// </remarks> |
| 34 | + internal static class iOSInfoPlistPostProcessor |
| 35 | + { |
| 36 | + internal const int CallbackOrder = 9050; |
| 37 | + internal const string AttributionDefine = "AUDIENCE_MOBILE_ATTRIBUTION"; |
| 38 | + |
| 39 | + [PostProcessBuild(CallbackOrder)] |
| 40 | + internal static void OnPostProcessBuild(BuildTarget target, string pathToBuiltProject) |
| 41 | + { |
| 42 | + if (target != BuildTarget.iOS) return; |
| 43 | + |
| 44 | +#if UNITY_IOS |
| 45 | + if (!AttributionDefineEnabled()) return; |
| 46 | + |
| 47 | + var plistPath = Path.Combine(pathToBuiltProject, "Info.plist"); |
| 48 | + if (!File.Exists(plistPath)) |
| 49 | + { |
| 50 | + Debug.LogWarning( |
| 51 | + $"[ImmutableAudience] iOS post-processor: Info.plist not found at {plistPath}. Skipping."); |
| 52 | + return; |
| 53 | + } |
| 54 | + |
| 55 | + var settings = AudienceMobileBuildSettings.FindAsset(); |
| 56 | + |
| 57 | + var plist = new PlistDocument(); |
| 58 | + plist.ReadFromFile(plistPath); |
| 59 | + |
| 60 | + ApplyTrackingUsageDescription(plist.root, settings); |
| 61 | + ApplySKAdNetworkItems(plist.root, settings); |
| 62 | + |
| 63 | + plist.WriteToFile(plistPath); |
| 64 | +#endif |
| 65 | + } |
| 66 | + |
| 67 | + // Sanity-check the settings asset without running a full iOS build. |
| 68 | + [MenuItem("Tools/Immutable/Audience/Validate iOS Build Settings")] |
| 69 | + private static void ValidateBuildSettings() |
| 70 | + { |
| 71 | + if (!AttributionDefineEnabled()) |
| 72 | + { |
| 73 | + Debug.LogWarning( |
| 74 | + "[ImmutableAudience] AUDIENCE_MOBILE_ATTRIBUTION scripting define is not set " + |
| 75 | + "for the iOS player target. The post-processor will not modify Info.plist. " + |
| 76 | + "Add the define under Player Settings → Other Settings → Scripting Define Symbols."); |
| 77 | + return; |
| 78 | + } |
| 79 | + |
| 80 | + var settings = AudienceMobileBuildSettings.FindAsset(); |
| 81 | + var description = settings != null |
| 82 | + ? settings.TrackingUsageDescription |
| 83 | + : AudienceMobileBuildSettings.DefaultTrackingUsageDescription; |
| 84 | + var ids = settings?.SKAdNetworkIds ?? new string[0]; |
| 85 | + |
| 86 | + Debug.Log( |
| 87 | + "[ImmutableAudience] iOS Info.plist injection preview\n" + |
| 88 | + $" NSUserTrackingUsageDescription: {description}\n" + |
| 89 | + $" SKAdNetworkItems: {ids.Length} id(s)\n" + |
| 90 | + (ids.Length == 0 |
| 91 | + ? " (no SKAdNetwork ids configured — set them on the AudienceMobileBuildSettings asset)\n" |
| 92 | + : string.Concat(System.Array.ConvertAll(ids, id => $" - {id}\n")))); |
| 93 | + } |
| 94 | + |
| 95 | + // Reads the iOS-target define list specifically — the post-processor |
| 96 | + // mutates iOS build output regardless of which target the editor is |
| 97 | + // currently focused on. |
| 98 | + private static bool AttributionDefineEnabled() |
| 99 | + { |
| 100 | + var defines = PlayerSettings.GetScriptingDefineSymbolsForGroup(BuildTargetGroup.iOS) ?? string.Empty; |
| 101 | + foreach (var define in defines.Split(';')) |
| 102 | + { |
| 103 | + if (define.Trim() == AttributionDefine) return true; |
| 104 | + } |
| 105 | + return false; |
| 106 | + } |
| 107 | + |
| 108 | +#if UNITY_IOS |
| 109 | + internal static void ApplyTrackingUsageDescription( |
| 110 | + PlistElementDict root, |
| 111 | + AudienceMobileBuildSettings? settings) |
| 112 | + { |
| 113 | + var description = settings != null |
| 114 | + ? settings.TrackingUsageDescription |
| 115 | + : AudienceMobileBuildSettings.DefaultTrackingUsageDescription; |
| 116 | + |
| 117 | + // Always overwrite — the settings asset is the source of truth, |
| 118 | + // beating any placeholder a lower-order post-processor wrote. |
| 119 | + root.SetString("NSUserTrackingUsageDescription", description); |
| 120 | + } |
| 121 | + |
| 122 | + internal static void ApplySKAdNetworkItems( |
| 123 | + PlistElementDict root, |
| 124 | + AudienceMobileBuildSettings? settings) |
| 125 | + { |
| 126 | + var ids = settings?.SKAdNetworkIds ?? new string[0]; |
| 127 | + if (ids.Length == 0) return; |
| 128 | + |
| 129 | + // Merge with any existing list so a lower-order post-processor's |
| 130 | + // entries aren't clobbered. Dedup is case-insensitive per Apple's |
| 131 | + // SKAdNetwork spec. |
| 132 | + PlistElementArray array; |
| 133 | + if (root.values.TryGetValue("SKAdNetworkItems", out var existing) && |
| 134 | + existing is PlistElementArray existingArray) |
| 135 | + { |
| 136 | + array = existingArray; |
| 137 | + } |
| 138 | + else |
| 139 | + { |
| 140 | + array = root.CreateArray("SKAdNetworkItems"); |
| 141 | + } |
| 142 | + |
| 143 | + var existingIds = new System.Collections.Generic.HashSet<string>( |
| 144 | + System.StringComparer.OrdinalIgnoreCase); |
| 145 | + foreach (var item in array.values) |
| 146 | + { |
| 147 | + if (item is PlistElementDict dict && |
| 148 | + dict.values.TryGetValue("SKAdNetworkIdentifier", out var idValue) && |
| 149 | + idValue is PlistElementString idString) |
| 150 | + { |
| 151 | + existingIds.Add(idString.value); |
| 152 | + } |
| 153 | + } |
| 154 | + |
| 155 | + foreach (var id in ids) |
| 156 | + { |
| 157 | + if (string.IsNullOrWhiteSpace(id)) continue; |
| 158 | + if (existingIds.Contains(id)) continue; |
| 159 | + |
| 160 | + var dict = array.AddDict(); |
| 161 | + dict.SetString("SKAdNetworkIdentifier", id); |
| 162 | + existingIds.Add(id); |
| 163 | + } |
| 164 | + } |
| 165 | +#endif |
| 166 | + } |
| 167 | +} |
0 commit comments