Skip to content

Commit 818e020

Browse files
nattb8claude
andcommitted
feat(audience-sdk): iOS App Tracking Transparency + IDFA support (SDK-306)
Adds an async ATT prompt API and IDFA collection to the iOS attribution surface introduced by SDK-307. * ImmutableAudience.RequestTrackingAuthorizationAsync() — public async API returning a 4-state TrackingAuthorizationStatus enum. Studios decide when to show the prompt; resolves to NotDetermined off iOS or on iOS<14. * attStatus + idfa shipped on game_launch when EnableMobileAttribution is true. IDFA is omitted unless the user has authorized. * AudienceMobileBridge.mm extended with _AudienceRequestATT (objc_msgSend cast preserves block typing) / _AudienceGetATTStatus (IMP-cast to read NSUInteger return) / _AudienceGetIDFA (filters Apple's all-zeros UUID). Runtime dispatch via NSClassFromString — no hard framework links. * ATTBridge.cs uses [MonoPInvokeCallback] + [Preserve] on the static status callback so the await completes under managed stripping=High; a static delegate field keeps the function pointer alive across Apple's async window. * iOSFrameworkPostProcessor adds AdSupport.framework (required) and AppTrackingTransparency.framework (weak) to the UnityFramework target when AUDIENCE_MOBILE_ATTRIBUTION is set — without explicit linkage the IDFA NSClassFromString lookup returns nil and the field is silently dropped. * Sample app: Mobile Attribution toggle and ATT button hide on Standalone (toggle is iOS+Android, button iOS-only). Button gated on API key + toggle so the result lands somewhere observable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9f9970b commit 818e020

19 files changed

Lines changed: 878 additions & 15 deletions

examples/audience/Assets/SampleApp/Resources/AudienceSample.uxml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,20 @@
9999
text="Mirror SDK internal log output into the in-page event log below." />
100100
</ui:VisualElement>
101101

102-
<ui:VisualElement class="field">
102+
<ui:VisualElement name="mobile-attribution-field" class="field">
103103
<ui:Toggle name="enable-mobile-attribution" label="MOBILE ATTRIBUTION" />
104104
<ui:Label class="helper-text below-field"
105105
text="Enable iOS SKAdNetwork registration and mobile attribution signals." />
106+
<ui:VisualElement name="att-section">
107+
<ui:VisualElement class="actions last">
108+
<ui:Button name="btn-request-att" class="with-sig">
109+
<ui:Label class="btn-label" text="Request ATT" />
110+
<ui:Label class="btn-sig" text="requestTrackingAuthorizationAsync()" />
111+
</ui:Button>
112+
</ui:VisualElement>
113+
<ui:Label class="helper-text below-field"
114+
text="iOS-only. Triggers the system ATT prompt on first call; subsequent calls return the cached status." />
115+
</ui:VisualElement>
106116
</ui:VisualElement>
107117

108118
<ui:VisualElement class="field placeholder-host">

examples/audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ private static readonly (string TabId, string PanelId)[] Tabs =
5353
private TextField _publishableKey, _baseUrl, _flushInterval, _flushSize;
5454
private DropdownField _initialConsent;
5555
private Toggle _debug, _enableMobileAttribution;
56-
private Button _btnInit, _btnFlush, _btnReset, _btnShutdown, _btnDeleteData;
56+
private Button _btnInit, _btnFlush, _btnReset, _btnShutdown, _btnDeleteData, _btnRequestAtt;
5757

5858
// ---- UXML element fields (Consent tab) ----
5959

@@ -197,10 +197,13 @@ private void BindElements()
197197
_flushSize = Require<TextField>("flush-size");
198198
_btnInit = Require<Button>("btn-init");
199199

200-
_btnFlush = Require<Button>("btn-flush");
201-
_btnReset = Require<Button>("btn-reset");
202-
_btnShutdown = Require<Button>("btn-shutdown");
203-
_btnDeleteData = Require<Button>("btn-delete-data");
200+
_btnFlush = Require<Button>("btn-flush");
201+
_btnReset = Require<Button>("btn-reset");
202+
_btnShutdown = Require<Button>("btn-shutdown");
203+
_btnDeleteData = Require<Button>("btn-delete-data");
204+
_btnRequestAtt = Require<Button>("btn-request-att");
205+
206+
ApplyMobilePlatformVisibility();
204207

205208
_consentPills = new Dictionary<ConsentLevel, Button>
206209
{
@@ -247,6 +250,24 @@ private void BindElements()
247250
_logCount = Require<Label>("log-count");
248251
}
249252

253+
// EnableMobileAttribution is a no-op on Standalone, so the toggle and
254+
// ATT button only appear on iOS/Android. ATT is iOS-only — Android
255+
// attribution uses a different signal — so the ATT subsection is
256+
// hidden on Android even when the toggle remains visible.
257+
// UNITY_IOS / UNITY_ANDROID also evaluate true in the Editor when the
258+
// matching build target is active, which is the right behavior for
259+
// testing.
260+
private void ApplyMobilePlatformVisibility()
261+
{
262+
#if !(UNITY_IOS || UNITY_ANDROID)
263+
var mobileField = _root.Q<VisualElement>("mobile-attribution-field");
264+
if (mobileField != null) mobileField.style.display = DisplayStyle.None;
265+
#elif !UNITY_IOS
266+
var attSection = _root.Q<VisualElement>("att-section");
267+
if (attSection != null) attSection.style.display = DisplayStyle.None;
268+
#endif
269+
}
270+
250271
private void PopulateDropdowns()
251272
{
252273
_initialConsent.choices = ConsentOrder.Select(c => c.ToLowercaseString()).ToList();
@@ -300,16 +321,18 @@ private void RegisterHandlers()
300321
_identifyId, _identifyTraits, _traitsUpdate,
301322
}) RegisterPlaceholder(field);
302323

303-
_publishableKey.RegisterValueChangedCallback(_ => { _btnInit.SetEnabled(!_initialised && !string.IsNullOrWhiteSpace(_publishableKey.value)); RefreshStatusBar(); });
324+
_publishableKey.RegisterValueChangedCallback(_ => { _btnInit.SetEnabled(!_initialised && !string.IsNullOrWhiteSpace(_publishableKey.value)); UpdateAttButtonGate(); RefreshStatusBar(); });
304325
_initialConsent.RegisterValueChangedCallback(_ => RefreshStatusBar());
305326
_baseUrl.RegisterValueChangedCallback(_ => RefreshStatusBar());
327+
_enableMobileAttribution.RegisterValueChangedCallback(_ => UpdateAttButtonGate());
306328

307329
_btnInit.clicked += OnInit;
308330

309331
_btnFlush.clicked += async () => await OnFlushAsync();
310332
_btnReset.clicked += OnReset;
311333
_btnShutdown.clicked += OnShutdown;
312334
_btnDeleteData.clicked += async () => await OnDeleteDataAsync();
335+
_btnRequestAtt.clicked += async () => await OnRequestAttAsync();
313336
_btnIdentify.clicked += OnIdentify;
314337
_btnIdentifyTraits.clicked += OnIdentifyTraits;
315338
_btnAlias.clicked += OnAlias;
@@ -621,6 +644,18 @@ private void RefreshInitState()
621644
foreach (var btn in _typedEventsHost.Query<Button>().ToList()) btn.SetEnabled(_initialised);
622645
_btnInit.SetEnabled(!_initialised && !string.IsNullOrWhiteSpace(_publishableKey.value));
623646
_btnAlias.SetEnabled(_initialised && IsAliasReady());
647+
UpdateAttButtonGate();
648+
}
649+
650+
// ATT prompt is independent of SDK init, but in the demo flow it's
651+
// only useful once a key is set (so events have somewhere to ship)
652+
// and the toggle is on (so the chosen status actually appears on
653+
// game_launch via MobileAttributionContextProvider).
654+
private void UpdateAttButtonGate()
655+
{
656+
_btnRequestAtt.SetEnabled(
657+
!string.IsNullOrWhiteSpace(_publishableKey.value) &&
658+
_enableMobileAttribution.value);
624659
}
625660

626661
private void RefreshIdentityPanel()

examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,21 @@ private async Task OnDeleteDataAsync()
101101
}
102102
}
103103

104+
private async Task OnRequestAttAsync()
105+
{
106+
AppendLog("requestTrackingAuthorizationAsync()", "ATT request dispatched", LogLevel.Info, LogSource.App);
107+
try
108+
{
109+
var status = await ImmutableAudience.RequestTrackingAuthorizationAsync();
110+
AppendLog("requestTrackingAuthorizationAsync()",
111+
$"status: {status}", LogLevel.Ok, LogSource.App);
112+
}
113+
catch (Exception ex)
114+
{
115+
AppendLog("requestTrackingAuthorizationAsync()", ex.Message, LogLevel.Err, LogSource.App);
116+
}
117+
}
118+
104119
// ---- SDK action handlers: telemetry ----
105120

106121
// Prefers the typed overload for the four events with public C#

examples/audience/ProjectSettings/ProjectSettings.asset

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,8 @@ PlayerSettings:
158158
androidSupportedAspectRatio: 1
159159
androidMaxAspectRatio: 2.1
160160
applicationIdentifier:
161-
iPhone: com.immutable.audience
162161
Android: com.immutable.audience
162+
iPhone: com.Immutable.audience
163163
buildNumber:
164164
Standalone: 0
165165
iPhone: 0
@@ -808,7 +808,8 @@ PlayerSettings:
808808
webGLThreadsSupport: 0
809809
webGLDecompressionFallback: 0
810810
webGLPowerPreference: 2
811-
scriptingDefineSymbols: {}
811+
scriptingDefineSymbols:
812+
iPhone: AUDIENCE_MOBILE_ATTRIBUTION
812813
additionalCompilerArguments: {}
813814
platformArchitecture: {}
814815
scriptingBackend:
@@ -817,6 +818,7 @@ PlayerSettings:
817818
iPhone: 1
818819
il2cppCompilerConfiguration: {}
819820
managedStrippingLevel:
821+
Android: 3
820822
EmbeddedLinux: 1
821823
GameCoreScarlett: 1
822824
GameCoreXboxOne: 1
@@ -829,7 +831,6 @@ PlayerSettings:
829831
WebGL: 1
830832
Windows Store Apps: 1
831833
XboxOne: 1
832-
Android: 3
833834
iPhone: 3
834835
tvOS: 1
835836
incrementalIl2cppBuild: {}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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+
/// Links the Apple frameworks the runtime mobile-attribution code needs
15+
/// into the generated iOS Xcode project.
16+
/// </summary>
17+
/// <remarks>
18+
/// <para>
19+
/// <c>AdSupport.framework</c> hosts <c>ASIdentifierManager</c> (the IDFA
20+
/// accessor). Without it linked, the runtime <c>NSClassFromString</c>
21+
/// lookup returns nil and the IDFA is silently dropped from
22+
/// <c>game_launch</c> — the Info.plist key alone does not load this
23+
/// framework. Linked as a required dep; the framework has shipped on
24+
/// every iOS release since 6.0.
25+
/// </para>
26+
/// <para>
27+
/// <c>AppTrackingTransparency.framework</c> hosts <c>ATTrackingManager</c>.
28+
/// Unity often auto-links this when <c>NSUserTrackingUsageDescription</c>
29+
/// is present, but the auto-link heuristic is undocumented and
30+
/// version-sensitive — an explicit weak link is immune to that drift and
31+
/// keeps the binary loadable on iOS &lt; 14 (the runtime code already
32+
/// guards via <c>NSClassFromString</c>).
33+
/// </para>
34+
/// <para>
35+
/// Gated on the same <c>AUDIENCE_MOBILE_ATTRIBUTION</c> scripting define
36+
/// as the Info.plist post-processor so studios who haven't opted into
37+
/// attribution ship a clean Frameworks list.
38+
/// </para>
39+
/// </remarks>
40+
internal static class iOSFrameworkPostProcessor
41+
{
42+
// Runs just after the Info.plist post-processor (9050). Order is
43+
// independent in practice — both edit different files — but keeping
44+
// them adjacent makes the build log obvious.
45+
internal const int CallbackOrder = 9051;
46+
47+
[PostProcessBuild(CallbackOrder)]
48+
internal static void OnPostProcessBuild(BuildTarget target, string pathToBuiltProject)
49+
{
50+
if (target != BuildTarget.iOS) return;
51+
52+
#if UNITY_IOS
53+
if (!AttributionDefineEnabled()) return;
54+
55+
var pbxPath = PBXProject.GetPBXProjectPath(pathToBuiltProject);
56+
if (!File.Exists(pbxPath))
57+
{
58+
Debug.LogWarning(
59+
$"[ImmutableAudience] iOS framework post-processor: project.pbxproj not found at {pbxPath}. Skipping.");
60+
return;
61+
}
62+
63+
var pbx = new PBXProject();
64+
pbx.ReadFromFile(pbxPath);
65+
66+
// Native plugin code under Runtime/Plugins/iOS compiles into the
67+
// UnityFramework target on Unity 2019.3+. Linking against the
68+
// main target instead leaves the symbols unresolved at runtime
69+
// and the IDFA / ATT calls silently no-op.
70+
var frameworkTarget = pbx.GetUnityFrameworkTargetGuid();
71+
72+
pbx.AddFrameworkToProject(frameworkTarget, "AdSupport.framework", weak: false);
73+
pbx.AddFrameworkToProject(frameworkTarget, "AppTrackingTransparency.framework", weak: true);
74+
75+
pbx.WriteToFile(pbxPath);
76+
#endif
77+
}
78+
79+
// Reads the iOS-target define list specifically — the post-processor
80+
// mutates iOS build output regardless of which target the editor is
81+
// currently focused on.
82+
private static bool AttributionDefineEnabled()
83+
{
84+
var defines = PlayerSettings.GetScriptingDefineSymbolsForGroup(BuildTargetGroup.iOS) ?? string.Empty;
85+
foreach (var define in defines.Split(';'))
86+
{
87+
if (define.Trim() == iOSInfoPlistPostProcessor.AttributionDefine) return true;
88+
}
89+
return false;
90+
}
91+
}
92+
}

src/Packages/Audience/Editor/iOSFrameworkPostProcessor.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/Runtime/ImmutableAudience.cs

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ public static class ImmutableAudience
5353
// Set by the Unity layer; null in pure-C# environments.
5454
internal static volatile Func<bool?>? MobileAttributionProvider;
5555

56+
// Called during Init when config.EnableMobileAttribution is true.
57+
// Returns iOS attribution context (attStatus, idfa) to merge into
58+
// game_launch properties. Set by the Unity layer.
59+
internal static volatile Func<IReadOnlyDictionary<string, object>?>? MobileAttributionContextProvider;
60+
61+
// Backs RequestTrackingAuthorizationAsync. Set by the Unity layer to
62+
// ATTBridge.RequestAsync; null in pure-C# environments and on
63+
// non-iOS platforms (the public API resolves to NotDetermined).
64+
internal static volatile Func<Task<int>>? TrackingAuthorizationRequestProvider;
65+
5666
// Active session. Created at Init (or on upgrade from None) and disposed
5767
// on Shutdown or SetConsent(None). Volatile so OnPause/OnResume see
5868
// assignments from SetConsent without taking _initLock.
@@ -210,13 +220,17 @@ public static void Init(AudienceConfig config)
210220
sessionToStart?.Start();
211221

212222
bool? skanRegistered = null;
223+
IReadOnlyDictionary<string, object>? attributionContext = null;
213224
if (config.EnableMobileAttribution)
214225
{
215226
try { skanRegistered = MobileAttributionProvider?.Invoke(); }
216227
catch (Exception ex) { Log.Warn(AudienceLogs.MobileAttributionProviderThrew(ex)); }
228+
229+
try { attributionContext = MobileAttributionContextProvider?.Invoke(); }
230+
catch (Exception ex) { Log.Warn(AudienceLogs.MobileAttributionContextProviderThrew(ex)); }
217231
}
218232

219-
FireGameLaunch(config, consentAtInit, skanRegistered);
233+
FireGameLaunch(config, consentAtInit, skanRegistered, attributionContext);
220234
}
221235

222236
// Pause/Resume hooks for the Unity lifecycle bridge.
@@ -677,6 +691,55 @@ private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel lev
677691
});
678692
}
679693

694+
// -----------------------------------------------------------------
695+
// Mobile attribution
696+
// -----------------------------------------------------------------
697+
698+
/// <summary>
699+
/// Requests the iOS App Tracking Transparency authorization. Triggers
700+
/// the system prompt on first call; returns the cached status on
701+
/// subsequent calls.
702+
/// </summary>
703+
/// <remarks>
704+
/// Studios decide when to show the prompt — Apple's HIG requires it
705+
/// to fire at a moment that makes the value to the player obvious,
706+
/// not at SDK Init. Resolves to
707+
/// <see cref="TrackingAuthorizationStatus.NotDetermined"/> on
708+
/// non-iOS platforms and on iOS &lt; 14. The IDFA, when authorized,
709+
/// is collected automatically on the next
710+
/// <see cref="ImmutableAudience.Init"/> via the
711+
/// <c>game_launch</c> event when
712+
/// <see cref="AudienceConfig.EnableMobileAttribution"/> is set.
713+
/// </remarks>
714+
/// <returns>
715+
/// A task that completes with the user's authorization decision.
716+
/// </returns>
717+
public static async Task<TrackingAuthorizationStatus> RequestTrackingAuthorizationAsync()
718+
{
719+
var provider = TrackingAuthorizationRequestProvider;
720+
if (provider == null)
721+
return TrackingAuthorizationStatus.NotDetermined;
722+
723+
int status;
724+
try
725+
{
726+
status = await provider().ConfigureAwait(false);
727+
}
728+
catch (Exception ex)
729+
{
730+
Log.Warn(AudienceLogs.TrackingAuthorizationRequestThrew(ex));
731+
return TrackingAuthorizationStatus.NotDetermined;
732+
}
733+
734+
// Defensively clamp: any value outside Apple's documented range
735+
// (0..3) is surfaced as NotDetermined rather than as an invalid
736+
// enum cast that callers can't pattern-match safely.
737+
if (status < 0 || status > 3)
738+
return TrackingAuthorizationStatus.NotDetermined;
739+
740+
return (TrackingAuthorizationStatus)status;
741+
}
742+
680743
// -----------------------------------------------------------------
681744
// Flush / Shutdown
682745
// -----------------------------------------------------------------
@@ -994,7 +1057,11 @@ private static void RescheduleSendTimer(HttpTransport transport)
9941057
}
9951058

9961059
// consentAtInit only gates the launch; Track still checks live _state via CanTrack.
997-
private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAtInit, bool? skanRegistered = null)
1060+
private static void FireGameLaunch(
1061+
AudienceConfig config,
1062+
ConsentLevel consentAtInit,
1063+
bool? skanRegistered = null,
1064+
IReadOnlyDictionary<string, object>? attributionContext = null)
9981065
{
9991066
if (!consentAtInit.CanTrack()) return;
10001067

@@ -1027,6 +1094,14 @@ private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAt
10271094
if (skanRegistered == true)
10281095
properties["skanRegistered"] = true;
10291096

1097+
// iOS ATT/IDFA snapshot — merged after Unity context so attribution
1098+
// keys are authoritative if both sources happen to set the same key.
1099+
if (attributionContext != null)
1100+
{
1101+
foreach (var kvp in attributionContext)
1102+
properties[kvp.Key] = kvp.Value;
1103+
}
1104+
10301105
// No sessionId on game_launch per Event Reference. Pipeline correlates
10311106
// via eventTimestamp with the session_start that fires just before.
10321107
Track("game_launch", properties.Count > 0 ? properties : null);

0 commit comments

Comments
 (0)