Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@
text="Mirror SDK internal log output into the in-page event log below." />
</ui:VisualElement>

<ui:VisualElement class="field">
<ui:Toggle name="enable-mobile-attribution" label="MOBILE ATTRIBUTION" />
<ui:Label class="helper-text below-field"
text="Enable iOS SKAdNetwork registration and mobile attribution signals." />
</ui:VisualElement>

<ui:VisualElement class="field placeholder-host">
<ui:Label class="field-label" text="BASE URL OVERRIDE" />
<ui:TextField name="base-url" />
Expand Down
22 changes: 13 additions & 9 deletions examples/audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ private static readonly (string TabId, string PanelId)[] Tabs =

private TextField _publishableKey, _baseUrl, _flushInterval, _flushSize;
private DropdownField _initialConsent;
private Toggle _debug;
private Toggle _debug, _enableMobileAttribution;
private Button _btnInit, _btnFlush, _btnReset, _btnShutdown, _btnDeleteData;

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

public InitForm(string publishableKey, string baseUrl, ConsentLevel consent, bool debug, int? flushIntervalMs, int? flushSize)
public InitForm(string publishableKey, string baseUrl, ConsentLevel consent, bool debug, bool enableMobileAttribution, int? flushIntervalMs, int? flushSize)
{
PublishableKey = publishableKey;
BaseUrl = baseUrl;
Consent = consent;
Debug = debug;
EnableMobileAttribution = enableMobileAttribution;
FlushIntervalMs = flushIntervalMs;
FlushSize = flushSize;
}
Expand All @@ -660,12 +663,13 @@ internal InitForm CaptureInitForm()
int? flushIntervalMs = int.TryParse((_flushInterval.value ?? "").Trim(), out var ms) && ms > 0 ? ms : (int?)null;
int? flushSize = int.TryParse((_flushSize.value ?? "").Trim(), out var size) && size > 0 ? size : (int?)null;
return new InitForm(
publishableKey: (_publishableKey.value ?? "").Trim(),
baseUrl: (_baseUrl.value ?? "").Trim(),
consent: ConsentOrder[consentIdx],
debug: _debug.value,
flushIntervalMs: flushIntervalMs,
flushSize: flushSize);
publishableKey: (_publishableKey.value ?? "").Trim(),
baseUrl: (_baseUrl.value ?? "").Trim(),
consent: ConsentOrder[consentIdx],
debug: _debug.value,
enableMobileAttribution: _enableMobileAttribution.value,
flushIntervalMs: flushIntervalMs,
flushSize: flushSize);
}

// Snapshot of the identify form on the Identity tab.
Expand Down
24 changes: 13 additions & 11 deletions examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs
Original file line number Diff line number Diff line change
Expand Up @@ -301,11 +301,12 @@ private AudienceConfig BuildAudienceConfig(InitForm form, Action<AudienceError>
{
var config = new AudienceConfig
{
PublishableKey = form.PublishableKey,
BaseUrl = string.IsNullOrEmpty(form.BaseUrl) ? null : form.BaseUrl,
Consent = form.Consent,
Debug = form.Debug,
OnError = onError,
PublishableKey = form.PublishableKey,
BaseUrl = string.IsNullOrEmpty(form.BaseUrl) ? null : form.BaseUrl,
Consent = form.Consent,
Debug = form.Debug,
EnableMobileAttribution = form.EnableMobileAttribution,
OnError = onError,
};
if (form.FlushIntervalMs is int flushMs && flushMs > 0)
{
Expand All @@ -324,12 +325,13 @@ private static Dictionary<string, object> BuildConfigEcho(AudienceConfig config)
{
var echo = new Dictionary<string, object>
{
["consent"] = config.Consent.ToString(),
["debug"] = config.Debug,
["flushIntervalSeconds"] = config.FlushIntervalSeconds,
["flushSize"] = config.FlushSize,
["packageVersion"] = config.PackageVersion,
["shutdownFlushTimeoutMs"] = config.ShutdownFlushTimeoutMs,
["consent"] = config.Consent.ToString(),
["debug"] = config.Debug,
["enableMobileAttribution"] = config.EnableMobileAttribution,
["flushIntervalSeconds"] = config.FlushIntervalSeconds,
["flushSize"] = config.FlushSize,
["packageVersion"] = config.PackageVersion,
["shutdownFlushTimeoutMs"] = config.ShutdownFlushTimeoutMs,
};
if (!string.IsNullOrEmpty(config.PublishableKey))
echo["publishableKey"] = RedactPublishableKey(config.PublishableKey);
Expand Down
20 changes: 18 additions & 2 deletions src/Packages/Audience/Runtime/ImmutableAudience.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ public static class ImmutableAudience
internal static volatile Func<IReadOnlyDictionary<string, object>>? LaunchContextProvider;
internal static volatile Func<IReadOnlyDictionary<string, object>>? ContextProvider;

// Called during Init when config.EnableMobileAttribution is true.
// Returns true on first SKAN registration, null if already done or not applicable.
// Set by the Unity layer; null in pure-C# environments.
internal static volatile Func<bool?>? MobileAttributionProvider;

// Active session. Created at Init (or on upgrade from None) and disposed
// on Shutdown or SetConsent(None). Volatile so OnPause/OnResume see
// assignments from SetConsent without taking _initLock.
Expand Down Expand Up @@ -204,7 +209,14 @@ public static void Init(AudienceConfig config)
// shows the new sessionId ahead of the launch event.
sessionToStart?.Start();

FireGameLaunch(config, consentAtInit);
bool? skanRegistered = null;
if (config.EnableMobileAttribution)
{
try { skanRegistered = MobileAttributionProvider?.Invoke(); }
catch (Exception ex) { Log.Warn(AudienceLogs.MobileAttributionProviderThrew(ex)); }
}

FireGameLaunch(config, consentAtInit, skanRegistered);
}

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

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

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

// Emitted only on the first launch where SKAN registration fires.
if (skanRegistered == true)
properties["skanRegistered"] = true;

// No sessionId on game_launch per Event Reference. Pipeline correlates
// via eventTimestamp with the session_start that fires just before.
Track("game_launch", properties.Count > 0 ? properties : null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,15 @@
return idfv ? strdup([idfv UTF8String]) : NULL;
}

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

}
5 changes: 5 additions & 0 deletions src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

using System.Collections.Generic;
using System.Collections.ObjectModel;
using Immutable.Audience.Unity.Mobile;
using UnityEngine;

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

#if UNITY_IOS && !UNITY_EDITOR
ImmutableAudience.MobileAttributionProvider = () => SkanRegistration.RegisterIfFirstLaunch();
#endif
Comment thread
cursor[bot] marked this conversation as resolved.

UnityLifecycleBridge.EnsureExists();

if (Log.Writer == null) Log.Writer = Debug.Log;
Expand Down
25 changes: 25 additions & 0 deletions src/Packages/Audience/Runtime/Unity/Mobile/SKANBridge.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#nullable enable

using System;
#if UNITY_IOS
using System.Runtime.InteropServices;
#endif

namespace Immutable.Audience.Unity.Mobile
{
internal static class SKANBridge
{
internal static Action Impl = NativeImpl;

internal static void Register() => Impl();

#if UNITY_IOS
[DllImport("__Internal")]
private static extern void _AudienceRegisterSKAN();

private static void NativeImpl() => _AudienceRegisterSKAN();
#else
private static void NativeImpl() { }
#endif
}
}
11 changes: 11 additions & 0 deletions src/Packages/Audience/Runtime/Unity/Mobile/SKANBridge.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions src/Packages/Audience/Runtime/Unity/Mobile/SkanRegistration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#nullable enable

using System;
using UnityEngine;

namespace Immutable.Audience.Unity.Mobile
{
internal static class SkanRegistration
{
private const string PrefsKey = "ImmutableAudience.skan_registered";

// Replaceable in tests.
internal static Func<bool> HasRegistered = DefaultHasRegistered;
internal static Action MarkRegistered = DefaultMarkRegistered;

// Returns true on first registration (SKAN was called), null if already done or N/A.
internal static bool? RegisterIfFirstLaunch()
{
if (HasRegistered()) return null;
SKANBridge.Register();
MarkRegistered();
return true;
}

private static bool DefaultHasRegistered() => PlayerPrefs.HasKey(PrefsKey);

private static void DefaultMarkRegistered()
{
PlayerPrefs.SetInt(PrefsKey, 1);
PlayerPrefs.Save();
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/Packages/Audience/Runtime/Utility/Log.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,5 +136,9 @@ internal static string ContextProviderThrew(Exception ex) =>
internal static string LaunchContextProviderThrew(Exception ex) =>
$"LaunchContextProvider threw {ex.GetType().Name}: {ex.Message}. " +
"game_launch will ship without auto-detected Unity context.";

internal static string MobileAttributionProviderThrew(Exception ex) =>
$"MobileAttributionProvider threw {ex.GetType().Name}: {ex.Message}. " +
"game_launch will ship without skanRegistered.";
}
}
49 changes: 49 additions & 0 deletions src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public void TearDown()
ImmutableAudience.LaunchContextProvider = null;
ImmutableAudience.ContextProvider = null;
ImmutableAudience.DefaultPersistentDataPathProvider = null;
ImmutableAudience.MobileAttributionProvider = null;
Identity.Reset(_testDir);
if (Directory.Exists(_testDir))
Directory.Delete(_testDir, recursive: true);
Expand Down Expand Up @@ -1241,6 +1242,54 @@ public void Init_GameLaunch_ProviderThrows_StillFiresEvent()
"game_launch must still ship when the context provider throws");
}

[Test]
public void Init_GameLaunch_IncludesSkanRegistered_WhenProviderReturnsTrue()
{
ImmutableAudience.MobileAttributionProvider = () => true;
var config = MakeConfig();
config.EnableMobileAttribution = true;
ImmutableAudience.Init(config);
ImmutableAudience.Shutdown();

var launchFile = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json")
.Select(File.ReadAllText)
.First(c => c.Contains("\"game_launch\""));
StringAssert.Contains("\"skanRegistered\":true", launchFile);
}

[Test]
public void Init_GameLaunch_OmitsSkanRegistered_WhenProviderReturnsNull()
{
ImmutableAudience.MobileAttributionProvider = () => null;
var config = MakeConfig();
config.EnableMobileAttribution = true;
ImmutableAudience.Init(config);
ImmutableAudience.Shutdown();

var launchFile = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json")
.Select(File.ReadAllText)
.First(c => c.Contains("\"game_launch\""));
Assert.IsFalse(launchFile.Contains("skanRegistered"));
}

[Test]
public void Init_GameLaunch_OmitsSkanRegistered_WhenMobileAttributionDisabled()
{
var callCount = 0;
ImmutableAudience.MobileAttributionProvider = () => { callCount++; return true; };
var config = MakeConfig();
config.EnableMobileAttribution = false;
ImmutableAudience.Init(config);
ImmutableAudience.Shutdown();

Assert.AreEqual(0, callCount,
"MobileAttributionProvider must not be called when EnableMobileAttribution is false");
var launchFile = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json")
.Select(File.ReadAllText)
.First(c => c.Contains("\"game_launch\""));
Assert.IsFalse(launchFile.Contains("skanRegistered"));
}

// -----------------------------------------------------------------
// Shutdown
// -----------------------------------------------------------------
Expand Down
Loading
Loading