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
48 changes: 48 additions & 0 deletions src/Packages/Audience/Runtime/Core/AttStatusStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#nullable enable

using System;
using System.IO;

namespace Immutable.Audience
{
internal static class AttStatusStore
{
internal static void Save(string persistentDataPath, int status)
{
var dir = AudiencePaths.AudienceDir(persistentDataPath);
Directory.CreateDirectory(dir);
var filePath = AudiencePaths.AttStatusFile(persistentDataPath);
var tmpPath = filePath + ".tmp";
File.WriteAllText(tmpPath, status.ToString());
try
{
File.Move(tmpPath, filePath);
}
catch (IOException)
{
File.Delete(filePath);
File.Move(tmpPath, filePath);
}
}

// Returns null on missing/malformed/unreadable file.
internal static int? Load(string persistentDataPath)
{
try
{
var filePath = AudiencePaths.AttStatusFile(persistentDataPath);
if (!File.Exists(filePath)) return null;
var text = File.ReadAllText(filePath).Trim();
if (int.TryParse(text, out var raw) && raw >= 0 && raw <= 3)
return raw;
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
return null;
}
}
}
11 changes: 11 additions & 0 deletions src/Packages/Audience/Runtime/Core/AttStatusStore.cs.meta

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/Core/AudiencePaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ internal static class AudiencePaths
private const string InstallReferrerFileName = "install_referrer";
private const string InstallReferrerSentFileName = "install_referrer_sent";
private const string GAIDFileName = "gaid";
private const string AttStatusFileName = "att_status";

internal static string AudienceDir(string persistentDataPath) =>
Path.Combine(persistentDataPath, RootDirName);
Expand All @@ -32,5 +33,8 @@ internal static string InstallReferrerSentFile(string persistentDataPath) =>

internal static string GAIDFile(string persistentDataPath) =>
Path.Combine(AudienceDir(persistentDataPath), GAIDFileName);

internal static string AttStatusFile(string persistentDataPath) =>
Path.Combine(AudienceDir(persistentDataPath), AttStatusFileName);
}
}
91 changes: 91 additions & 0 deletions src/Packages/Audience/Runtime/ImmutableAudience.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ public static class ImmutableAudience
// layer; null in pure-C# environments and on non-Android platforms.
internal static volatile Func<string?>? MobileInstallReferrerProvider;

// Returns the current iOS ATT status int (0=notDetermined, 1=restricted,
// 2=denied, 3=authorized). Used by tracking_authorization_changed detection
// on Init and OnResume. Set by the Unity layer on iOS; null elsewhere.
internal static volatile Func<int?>? MobileATTStatusProvider;

// Returns the IDFA string when ATT is authorized. Included in
// tracking_authorization_changed only when transitioning to authorized
// with Full consent. Set by the Unity layer on iOS; null elsewhere.
internal static volatile Func<string?>? MobileIDFAProvider;

// 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 @@ -249,6 +259,8 @@ public static void Init(AudienceConfig config)

FireGameLaunch(config, consentAtInit, skanRegistered, attributionContext);

CheckAndFireAttStatusChanged(config, consentAtInit);

// Fires once per install. installReferrer lands asynchronously
// from Google Play Services; on the first launch the cache is
// usually still empty when game_launch fires, so we ship a
Expand Down Expand Up @@ -774,6 +786,11 @@ public static async Task<TrackingAuthorizationStatus> RequestTrackingAuthorizati
if (status < 0 || status > 3)
return TrackingAuthorizationStatus.NotDetermined;

// Pass the resolved status directly to avoid a redundant native call.
var config = _config;
if (_initialized && config != null)
CheckAndFireAttStatusChanged(config, _state.Level, status);

return (TrackingAuthorizationStatus)status;
}

Expand Down Expand Up @@ -1182,5 +1199,79 @@ private static void FireInstallReferrerReceivedOnce(AudienceConfig config, strin
Log.Warn(AudienceLogs.InstallReferrerSentMarkerWriteFailed(ex));
}
}

// Mirrors AttributionContext.AttStatusToString in the Unity layer; defined
// here so the Core assembly has no dependency on the Unity assembly.
private static string AttStatusToString(int status)
{
switch (status)
{
case 0: return "notDetermined";
case 1: return "restricted";
case 2: return "denied";
case 3: return "authorized";
default: return "unknown";
}
}

// Fires tracking_authorization_changed when the ATT status differs from
// the last-persisted observation. knownStatus skips the native re-read
// when the caller already has the resolved value (e.g. after
// RequestTrackingAuthorizationAsync resolves).
//
// First observation (no file): persists the baseline and returns without
// firing — game_launch already captures the initial state on that Init.
private static void CheckAndFireAttStatusChanged(
AudienceConfig config,
ConsentLevel consent,
int? knownStatus = null)
{
if (!config.EnableMobileAttribution) return;
if (!consent.CanTrack()) return;

int currentStatus;
if (knownStatus.HasValue)
{
currentStatus = knownStatus.Value;
}
else
{
var provider = MobileATTStatusProvider;
if (provider == null) return;
int? raw;
try { raw = provider(); }
catch (Exception ex) { Log.Warn(AudienceLogs.ATTStatusProviderThrew(ex)); return; }
if (!raw.HasValue) return;
currentStatus = raw.Value;
}

var previous = AttStatusStore.Load(config.PersistentDataPath!);

if (previous == currentStatus) return;

AttStatusStore.Save(config.PersistentDataPath!, currentStatus);

if (!previous.HasValue)
return; // first observation: no transition to report

var props = new Dictionary<string, object>
{
["previousStatus"] = AttStatusToString(previous.Value),
["newStatus"] = AttStatusToString(currentStatus),
};

if (currentStatus == 3 && consent.CanIdentify())
{
try
{
var idfa = MobileIDFAProvider?.Invoke();
if (!string.IsNullOrEmpty(idfa))
props["idfa"] = idfa!;
}
catch (Exception ex) { Log.Warn(AudienceLogs.ATTIDFAProviderThrew(ex)); }
}

Track("tracking_authorization_changed", props);
}
}
}
2 changes: 2 additions & 0 deletions src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ private static void Install()
ImmutableAudience.MobileAttributionProvider = () => SkanRegistration.RegisterIfFirstLaunch();
ImmutableAudience.MobileAttributionContextProvider = () => AttributionContext.Capture();
ImmutableAudience.TrackingAuthorizationRequestProvider = () => ATTBridge.RequestAsync();
ImmutableAudience.MobileATTStatusProvider = () => ATTBridge.GetStatus();
ImmutableAudience.MobileIDFAProvider = () => ATTBridge.GetIDFA();
#endif

#if UNITY_ANDROID && !UNITY_EDITOR
Expand Down
8 changes: 8 additions & 0 deletions src/Packages/Audience/Runtime/Utility/Log.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@ internal static string InstallReferrerSentMarkerWriteFailed(Exception ex) =>
$"Failed to write install_referrer_sent marker: {ex.GetType().Name}: {ex.Message}. " +
"install_referrer_received may re-fire on the next launch.";

internal static string ATTStatusProviderThrew(Exception ex) =>
$"MobileATTStatusProvider threw {ex.GetType().Name}: {ex.Message}. " +
"tracking_authorization_changed check skipped.";

internal static string ATTIDFAProviderThrew(Exception ex) =>
$"MobileIDFAProvider threw {ex.GetType().Name}: {ex.Message}. " +
"tracking_authorization_changed will ship without idfa.";

internal static string GAIDFetchThrew(Exception ex) =>
$"GAID fetch threw {ex.GetType().Name}: {ex.Message}. " +
"gaid will not ship on game_launch this session; next launch retries.";
Expand Down
Loading
Loading