Skip to content

Commit d8e56f0

Browse files
authored
Merge pull request #744 from immutable/feat/sdk-308-ios-info-plist-post-processor
feat(audience-sdk): iOS Info.plist post-processor for ATT + SKAdNetwork (SDK-308)
2 parents 0495c1b + a56fea7 commit d8e56f0

11 files changed

Lines changed: 523 additions & 0 deletions
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
using System.Runtime.CompilerServices;
2+
3+
[assembly: InternalsVisibleTo("Immutable.Audience.Editor.Tests")]

src/Packages/Audience/Editor/AssemblyInfo.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#nullable enable
2+
3+
using UnityEditor;
4+
using UnityEngine;
5+
6+
namespace Immutable.Audience.Editor
7+
{
8+
/// <summary>
9+
/// Build-time iOS settings injected into the generated Xcode project's
10+
/// <c>Info.plist</c> by <see cref="iOSInfoPlistPostProcessor"/>.
11+
/// </summary>
12+
/// <remarks>
13+
/// The runtime <c>AudienceConfig</c> can't be read at build time, so the
14+
/// iOS post-processor needs an asset-backed source of truth for values
15+
/// that must land in <c>Info.plist</c> before the binary is signed. The
16+
/// post-processor finds the asset by type, so studios can keep it
17+
/// wherever fits their project layout.
18+
/// </remarks>
19+
public sealed class AudienceMobileBuildSettings : ScriptableObject
20+
{
21+
// Fallback so a build never ships with the key missing (which would
22+
// block App Store submission). Studios should override on the asset
23+
// with copy describing what is collected and why.
24+
internal const string DefaultTrackingUsageDescription =
25+
"Your data may be used to deliver personalised ads to you. " +
26+
"You can change this preference at any time in Settings.";
27+
28+
[SerializeField]
29+
[Tooltip("Copy shown in the iOS App Tracking Transparency prompt. " +
30+
"Apple rejects empty or generic strings — describe what is " +
31+
"collected and why.")]
32+
private string trackingUsageDescription = DefaultTrackingUsageDescription;
33+
34+
[SerializeField]
35+
[Tooltip("SKAdNetwork IDs (e.g. \"abc123.skadnetwork\") to register " +
36+
"with the App Store as supported ad networks. Provided by " +
37+
"your ad partners.")]
38+
private string[] skAdNetworkIds = new string[0];
39+
40+
public string TrackingUsageDescription =>
41+
string.IsNullOrWhiteSpace(trackingUsageDescription)
42+
? DefaultTrackingUsageDescription
43+
: trackingUsageDescription;
44+
45+
public string[] SKAdNetworkIds => skAdNetworkIds ?? new string[0];
46+
47+
[MenuItem("Assets/Create/Immutable Audience/Mobile Build Settings", priority = 100)]
48+
private static void CreateAsset()
49+
{
50+
var asset = CreateInstance<AudienceMobileBuildSettings>();
51+
ProjectWindowUtil.CreateAsset(asset, "AudienceMobileBuildSettings.asset");
52+
}
53+
54+
/// <summary>
55+
/// Locates the first asset under <c>Assets/</c>, or <c>null</c> if
56+
/// none exists.
57+
/// </summary>
58+
internal static AudienceMobileBuildSettings? FindAsset()
59+
{
60+
var guids = AssetDatabase.FindAssets($"t:{nameof(AudienceMobileBuildSettings)}");
61+
if (guids.Length == 0) return null;
62+
63+
var path = AssetDatabase.GUIDToAssetPath(guids[0]);
64+
if (guids.Length > 1)
65+
{
66+
Debug.LogWarning(
67+
$"[ImmutableAudience] Multiple AudienceMobileBuildSettings assets found — " +
68+
$"using '{path}'. Remove the duplicates to avoid unexpected build behaviour.");
69+
}
70+
return AssetDatabase.LoadAssetAtPath<AudienceMobileBuildSettings>(path);
71+
}
72+
}
73+
}

src/Packages/Audience/Editor/AudienceMobileBuildSettings.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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+
}

src/Packages/Audience/Editor/iOSInfoPlistPostProcessor.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Packages/Audience/Tests/Editor.meta

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "Immutable.Audience.Editor.Tests",
3+
"rootNamespace": "Immutable.Audience.Editor.Tests",
4+
"references": [
5+
"Immutable.Audience.Runtime",
6+
"Immutable.Audience.Editor",
7+
"UnityEngine.TestRunner",
8+
"UnityEditor.TestRunner"
9+
],
10+
"includePlatforms": ["Editor"],
11+
"excludePlatforms": [],
12+
"allowUnsafeCode": false,
13+
"overrideReferences": true,
14+
"precompiledReferences": ["nunit.framework.dll"],
15+
"autoReferenced": false,
16+
"defineConstraints": ["UNITY_INCLUDE_TESTS"],
17+
"versionDefines": [],
18+
"noEngineReferences": false
19+
}

src/Packages/Audience/Tests/Editor/com.immutable.audience.editor.tests.asmdef.meta

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)