Skip to content

Commit 6274d79

Browse files
nattb8claude
andcommitted
feat(audience-sdk): iOS SKAdNetwork registration on first launch (SDK-307)
Calls SKAdNetwork.registerAppForAdNetworkAttribution() via Obj-C++ bridge on the first app launch when config.EnableMobileAttribution is true. Subsequent launches skip the call (PlayerPrefs flag persists per install). Emits skanRegistered: true on the game_launch event only when registration fires. Runtime dispatch avoids a hard StoreKit link and the In-App Purchase capability requirement. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d8e56f0 commit 6274d79

14 files changed

Lines changed: 288 additions & 22 deletions

File tree

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

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

102+
<ui:VisualElement class="field">
103+
<ui:Toggle name="enable-mobile-attribution" label="MOBILE ATTRIBUTION" />
104+
<ui:Label class="helper-text below-field"
105+
text="Enable iOS SKAdNetwork registration and mobile attribution signals." />
106+
</ui:VisualElement>
107+
102108
<ui:VisualElement class="field placeholder-host">
103109
<ui:Label class="field-label" text="BASE URL OVERRIDE" />
104110
<ui:TextField name="base-url" />

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ private static readonly (string TabId, string PanelId)[] Tabs =
5252

5353
private TextField _publishableKey, _baseUrl, _flushInterval, _flushSize;
5454
private DropdownField _initialConsent;
55-
private Toggle _debug;
55+
private Toggle _debug, _enableMobileAttribution;
5656
private Button _btnInit, _btnFlush, _btnReset, _btnShutdown, _btnDeleteData;
5757

5858
// ---- UXML element fields (Consent tab) ----
@@ -180,7 +180,8 @@ private void BindElements()
180180
_publishableKey = Require<TextField>("publishable-key");
181181
_baseUrl = Require<TextField>("base-url");
182182
_initialConsent = Require<DropdownField>("initial-consent");
183-
_debug = Require<Toggle>("debug");
183+
_debug = Require<Toggle>("debug");
184+
_enableMobileAttribution = Require<Toggle>("enable-mobile-attribution");
184185
// Inject a tick Label — Unity 2021.3 runtime panels render the
185186
// checked state as a plain coloured square otherwise. USS hides
186187
// the tick when unchecked.
@@ -640,15 +641,17 @@ internal readonly struct InitForm
640641
public readonly string BaseUrl;
641642
public readonly ConsentLevel Consent;
642643
public readonly bool Debug;
644+
public readonly bool EnableMobileAttribution;
643645
public readonly int? FlushIntervalMs;
644646
public readonly int? FlushSize;
645647

646-
public InitForm(string publishableKey, string baseUrl, ConsentLevel consent, bool debug, int? flushIntervalMs, int? flushSize)
648+
public InitForm(string publishableKey, string baseUrl, ConsentLevel consent, bool debug, bool enableMobileAttribution, int? flushIntervalMs, int? flushSize)
647649
{
648650
PublishableKey = publishableKey;
649651
BaseUrl = baseUrl;
650652
Consent = consent;
651653
Debug = debug;
654+
EnableMobileAttribution = enableMobileAttribution;
652655
FlushIntervalMs = flushIntervalMs;
653656
FlushSize = flushSize;
654657
}
@@ -660,12 +663,13 @@ internal InitForm CaptureInitForm()
660663
int? flushIntervalMs = int.TryParse((_flushInterval.value ?? "").Trim(), out var ms) && ms > 0 ? ms : (int?)null;
661664
int? flushSize = int.TryParse((_flushSize.value ?? "").Trim(), out var size) && size > 0 ? size : (int?)null;
662665
return new InitForm(
663-
publishableKey: (_publishableKey.value ?? "").Trim(),
664-
baseUrl: (_baseUrl.value ?? "").Trim(),
665-
consent: ConsentOrder[consentIdx],
666-
debug: _debug.value,
667-
flushIntervalMs: flushIntervalMs,
668-
flushSize: flushSize);
666+
publishableKey: (_publishableKey.value ?? "").Trim(),
667+
baseUrl: (_baseUrl.value ?? "").Trim(),
668+
consent: ConsentOrder[consentIdx],
669+
debug: _debug.value,
670+
enableMobileAttribution: _enableMobileAttribution.value,
671+
flushIntervalMs: flushIntervalMs,
672+
flushSize: flushSize);
669673
}
670674

671675
// Snapshot of the identify form on the Identity tab.

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

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -301,11 +301,12 @@ private AudienceConfig BuildAudienceConfig(InitForm form, Action<AudienceError>
301301
{
302302
var config = new AudienceConfig
303303
{
304-
PublishableKey = form.PublishableKey,
305-
BaseUrl = string.IsNullOrEmpty(form.BaseUrl) ? null : form.BaseUrl,
306-
Consent = form.Consent,
307-
Debug = form.Debug,
308-
OnError = onError,
304+
PublishableKey = form.PublishableKey,
305+
BaseUrl = string.IsNullOrEmpty(form.BaseUrl) ? null : form.BaseUrl,
306+
Consent = form.Consent,
307+
Debug = form.Debug,
308+
EnableMobileAttribution = form.EnableMobileAttribution,
309+
OnError = onError,
309310
};
310311
if (form.FlushIntervalMs is int flushMs && flushMs > 0)
311312
{
@@ -324,12 +325,13 @@ private static Dictionary<string, object> BuildConfigEcho(AudienceConfig config)
324325
{
325326
var echo = new Dictionary<string, object>
326327
{
327-
["consent"] = config.Consent.ToString(),
328-
["debug"] = config.Debug,
329-
["flushIntervalSeconds"] = config.FlushIntervalSeconds,
330-
["flushSize"] = config.FlushSize,
331-
["packageVersion"] = config.PackageVersion,
332-
["shutdownFlushTimeoutMs"] = config.ShutdownFlushTimeoutMs,
328+
["consent"] = config.Consent.ToString(),
329+
["debug"] = config.Debug,
330+
["enableMobileAttribution"] = config.EnableMobileAttribution,
331+
["flushIntervalSeconds"] = config.FlushIntervalSeconds,
332+
["flushSize"] = config.FlushSize,
333+
["packageVersion"] = config.PackageVersion,
334+
["shutdownFlushTimeoutMs"] = config.ShutdownFlushTimeoutMs,
333335
};
334336
if (!string.IsNullOrEmpty(config.PublishableKey))
335337
echo["publishableKey"] = RedactPublishableKey(config.PublishableKey);

src/Packages/Audience/Runtime/ImmutableAudience.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ public static class ImmutableAudience
4848
internal static volatile Func<IReadOnlyDictionary<string, object>>? LaunchContextProvider;
4949
internal static volatile Func<IReadOnlyDictionary<string, object>>? ContextProvider;
5050

51+
// Called during Init when config.EnableMobileAttribution is true.
52+
// Returns true on first SKAN registration, null if already done or not applicable.
53+
// Set by the Unity layer; null in pure-C# environments.
54+
internal static volatile Func<bool?>? MobileAttributionProvider;
55+
5156
// Active session. Created at Init (or on upgrade from None) and disposed
5257
// on Shutdown or SetConsent(None). Volatile so OnPause/OnResume see
5358
// assignments from SetConsent without taking _initLock.
@@ -204,7 +209,14 @@ public static void Init(AudienceConfig config)
204209
// shows the new sessionId ahead of the launch event.
205210
sessionToStart?.Start();
206211

207-
FireGameLaunch(config, consentAtInit);
212+
bool? skanRegistered = null;
213+
if (config.EnableMobileAttribution)
214+
{
215+
try { skanRegistered = MobileAttributionProvider?.Invoke(); }
216+
catch (Exception ex) { Log.Warn(AudienceLogs.MobileAttributionProviderThrew(ex)); }
217+
}
218+
219+
FireGameLaunch(config, consentAtInit, skanRegistered);
208220
}
209221

210222
// Pause/Resume hooks for the Unity lifecycle bridge.
@@ -982,7 +994,7 @@ private static void RescheduleSendTimer(HttpTransport transport)
982994
}
983995

984996
// consentAtInit only gates the launch; Track still checks live _state via CanTrack.
985-
private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAtInit)
997+
private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAtInit, bool? skanRegistered = null)
986998
{
987999
if (!consentAtInit.CanTrack()) return;
9881000

@@ -1011,6 +1023,10 @@ private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAt
10111023
if (config.DistributionPlatform != null)
10121024
properties["distributionPlatform"] = config.DistributionPlatform;
10131025

1026+
// Emitted only on the first launch where SKAN registration fires.
1027+
if (skanRegistered == true)
1028+
properties["skanRegistered"] = true;
1029+
10141030
// No sessionId on game_launch per Event Reference. Pipeline correlates
10151031
// via eventTimestamp with the session_start that fires just before.
10161032
Track("game_launch", properties.Count > 0 ? properties : null);

src/Packages/Audience/Runtime/Plugins/iOS/AudienceMobileBridge.mm

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,15 @@
1212
return idfv ? strdup([idfv UTF8String]) : NULL;
1313
}
1414

15+
void _AudienceRegisterSKAN(void)
16+
{
17+
// Runtime dispatch avoids a hard link to StoreKit.framework, which would
18+
// trigger Xcode's In-App Purchase capability check. StoreKit is always
19+
// present on device; NSClassFromString finds it without a compile-time dep.
20+
Class cls = NSClassFromString(@"SKAdNetwork");
21+
if (cls) {
22+
[cls performSelector:@selector(registerAppForAdNetworkAttribution)];
23+
}
24+
}
25+
1526
}

src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
using System.Collections.Generic;
44
using System.Collections.ObjectModel;
5+
using Immutable.Audience.Unity.Mobile;
56
using UnityEngine;
67

78
namespace Immutable.Audience.Unity
@@ -27,6 +28,10 @@ private static void Install()
2728
ImmutableAudience.LaunchContextProvider = () => launchProps;
2829
ImmutableAudience.ContextProvider = () => contextProps;
2930

31+
#if UNITY_IOS && !UNITY_EDITOR
32+
ImmutableAudience.MobileAttributionProvider = () => SkanRegistration.RegisterIfFirstLaunch();
33+
#endif
34+
3035
UnityLifecycleBridge.EnsureExists();
3136

3237
if (Log.Writer == null) Log.Writer = Debug.Log;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#nullable enable
2+
3+
using System;
4+
#if UNITY_IOS
5+
using System.Runtime.InteropServices;
6+
#endif
7+
8+
namespace Immutable.Audience.Unity.Mobile
9+
{
10+
internal static class SKANBridge
11+
{
12+
internal static Action Impl = NativeImpl;
13+
14+
internal static void Register() => Impl();
15+
16+
#if UNITY_IOS
17+
[DllImport("__Internal")]
18+
private static extern void _AudienceRegisterSKAN();
19+
20+
private static void NativeImpl() => _AudienceRegisterSKAN();
21+
#else
22+
private static void NativeImpl() { }
23+
#endif
24+
}
25+
}

src/Packages/Audience/Runtime/Unity/Mobile/SKANBridge.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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#nullable enable
2+
3+
using System;
4+
using UnityEngine;
5+
6+
namespace Immutable.Audience.Unity.Mobile
7+
{
8+
internal static class SkanRegistration
9+
{
10+
private const string PrefsKey = "ImmutableAudience.skan_registered";
11+
12+
// Replaceable in tests.
13+
internal static Func<bool> HasRegistered = DefaultHasRegistered;
14+
internal static Action MarkRegistered = DefaultMarkRegistered;
15+
16+
// Returns true on first registration (SKAN was called), null if already done or N/A.
17+
internal static bool? RegisterIfFirstLaunch()
18+
{
19+
if (HasRegistered()) return null;
20+
SKANBridge.Register();
21+
MarkRegistered();
22+
return true;
23+
}
24+
25+
private static bool DefaultHasRegistered() => PlayerPrefs.HasKey(PrefsKey);
26+
27+
private static void DefaultMarkRegistered()
28+
{
29+
PlayerPrefs.SetInt(PrefsKey, 1);
30+
PlayerPrefs.Save();
31+
}
32+
}
33+
}

src/Packages/Audience/Runtime/Unity/Mobile/SkanRegistration.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.

0 commit comments

Comments
 (0)