Skip to content

Commit ca4e26b

Browse files
nattb8claude
andcommitted
feat(audience-sdk): add tracking_authorization_changed event (SDK-332)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 65d8810 commit ca4e26b

7 files changed

Lines changed: 338 additions & 0 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#nullable enable
2+
3+
using System;
4+
using System.IO;
5+
6+
namespace Immutable.Audience
7+
{
8+
internal static class AttStatusStore
9+
{
10+
internal static void Save(string persistentDataPath, int status)
11+
{
12+
var dir = AudiencePaths.AudienceDir(persistentDataPath);
13+
Directory.CreateDirectory(dir);
14+
var filePath = AudiencePaths.AttStatusFile(persistentDataPath);
15+
var tmpPath = filePath + ".tmp";
16+
File.WriteAllText(tmpPath, status.ToString());
17+
try
18+
{
19+
File.Move(tmpPath, filePath);
20+
}
21+
catch (IOException)
22+
{
23+
File.Delete(filePath);
24+
File.Move(tmpPath, filePath);
25+
}
26+
}
27+
28+
// Returns null on missing/malformed/unreadable file.
29+
internal static int? Load(string persistentDataPath)
30+
{
31+
try
32+
{
33+
var filePath = AudiencePaths.AttStatusFile(persistentDataPath);
34+
if (!File.Exists(filePath)) return null;
35+
var text = File.ReadAllText(filePath).Trim();
36+
if (int.TryParse(text, out var raw) && raw >= 0 && raw <= 3)
37+
return raw;
38+
}
39+
catch (IOException)
40+
{
41+
}
42+
catch (UnauthorizedAccessException)
43+
{
44+
}
45+
return null;
46+
}
47+
}
48+
}

src/Packages/Audience/Runtime/Core/AttStatusStore.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/Core/AudiencePaths.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ internal static class AudiencePaths
1111
private const string InstallReferrerFileName = "install_referrer";
1212
private const string InstallReferrerSentFileName = "install_referrer_sent";
1313
private const string GAIDFileName = "gaid";
14+
private const string AttStatusFileName = "att_status";
1415

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

3334
internal static string GAIDFile(string persistentDataPath) =>
3435
Path.Combine(AudienceDir(persistentDataPath), GAIDFileName);
36+
37+
internal static string AttStatusFile(string persistentDataPath) =>
38+
Path.Combine(AudienceDir(persistentDataPath), AttStatusFileName);
3539
}
3640
}

src/Packages/Audience/Runtime/ImmutableAudience.cs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ public static class ImmutableAudience
7070
// layer; null in pure-C# environments and on non-Android platforms.
7171
internal static volatile Func<string?>? MobileInstallReferrerProvider;
7272

73+
// Returns the current iOS ATT status int (0=notDetermined, 1=restricted,
74+
// 2=denied, 3=authorized). Used by tracking_authorization_changed detection
75+
// on Init and OnResume. Set by the Unity layer on iOS; null elsewhere.
76+
internal static volatile Func<int?>? MobileATTStatusProvider;
77+
78+
// Returns the IDFA string when ATT is authorized. Included in
79+
// tracking_authorization_changed only when transitioning to authorized
80+
// with Full consent. Set by the Unity layer on iOS; null elsewhere.
81+
internal static volatile Func<string?>? MobileIDFAProvider;
82+
7383
// Active session. Created at Init (or on upgrade from None) and disposed
7484
// on Shutdown or SetConsent(None). Volatile so OnPause/OnResume see
7585
// assignments from SetConsent without taking _initLock.
@@ -249,6 +259,8 @@ public static void Init(AudienceConfig config)
249259

250260
FireGameLaunch(config, consentAtInit, skanRegistered, attributionContext);
251261

262+
CheckAndFireAttStatusChanged(config, consentAtInit);
263+
252264
// Fires once per install. installReferrer lands asynchronously
253265
// from Google Play Services; on the first launch the cache is
254266
// usually still empty when game_launch fires, so we ship a
@@ -774,6 +786,11 @@ public static async Task<TrackingAuthorizationStatus> RequestTrackingAuthorizati
774786
if (status < 0 || status > 3)
775787
return TrackingAuthorizationStatus.NotDetermined;
776788

789+
// Pass the resolved status directly to avoid a redundant native call.
790+
var config = _config;
791+
if (_initialized && config != null)
792+
CheckAndFireAttStatusChanged(config, _state.Level, status);
793+
777794
return (TrackingAuthorizationStatus)status;
778795
}
779796

@@ -1182,5 +1199,79 @@ private static void FireInstallReferrerReceivedOnce(AudienceConfig config, strin
11821199
Log.Warn(AudienceLogs.InstallReferrerSentMarkerWriteFailed(ex));
11831200
}
11841201
}
1202+
1203+
// Mirrors AttributionContext.AttStatusToString in the Unity layer; defined
1204+
// here so the Core assembly has no dependency on the Unity assembly.
1205+
private static string AttStatusToString(int status)
1206+
{
1207+
switch (status)
1208+
{
1209+
case 0: return "notDetermined";
1210+
case 1: return "restricted";
1211+
case 2: return "denied";
1212+
case 3: return "authorized";
1213+
default: return "unknown";
1214+
}
1215+
}
1216+
1217+
// Fires tracking_authorization_changed when the ATT status differs from
1218+
// the last-persisted observation. knownStatus skips the native re-read
1219+
// when the caller already has the resolved value (e.g. after
1220+
// RequestTrackingAuthorizationAsync resolves).
1221+
//
1222+
// First observation (no file): persists the baseline and returns without
1223+
// firing — game_launch already captures the initial state on that Init.
1224+
private static void CheckAndFireAttStatusChanged(
1225+
AudienceConfig config,
1226+
ConsentLevel consent,
1227+
int? knownStatus = null)
1228+
{
1229+
if (!config.EnableMobileAttribution) return;
1230+
if (!consent.CanTrack()) return;
1231+
1232+
int currentStatus;
1233+
if (knownStatus.HasValue)
1234+
{
1235+
currentStatus = knownStatus.Value;
1236+
}
1237+
else
1238+
{
1239+
var provider = MobileATTStatusProvider;
1240+
if (provider == null) return;
1241+
int? raw;
1242+
try { raw = provider(); }
1243+
catch (Exception ex) { Log.Warn(AudienceLogs.ATTStatusProviderThrew(ex)); return; }
1244+
if (!raw.HasValue) return;
1245+
currentStatus = raw.Value;
1246+
}
1247+
1248+
var previous = AttStatusStore.Load(config.PersistentDataPath!);
1249+
1250+
if (previous == currentStatus) return;
1251+
1252+
AttStatusStore.Save(config.PersistentDataPath!, currentStatus);
1253+
1254+
if (!previous.HasValue)
1255+
return; // first observation: no transition to report
1256+
1257+
var props = new Dictionary<string, object>
1258+
{
1259+
["previousStatus"] = AttStatusToString(previous.Value),
1260+
["newStatus"] = AttStatusToString(currentStatus),
1261+
};
1262+
1263+
if (currentStatus == 3 && consent.CanIdentify())
1264+
{
1265+
try
1266+
{
1267+
var idfa = MobileIDFAProvider?.Invoke();
1268+
if (!string.IsNullOrEmpty(idfa))
1269+
props["idfa"] = idfa!;
1270+
}
1271+
catch (Exception ex) { Log.Warn(AudienceLogs.ATTIDFAProviderThrew(ex)); }
1272+
}
1273+
1274+
Track("tracking_authorization_changed", props);
1275+
}
11851276
}
11861277
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ private static void Install()
3939
ImmutableAudience.MobileAttributionProvider = () => SkanRegistration.RegisterIfFirstLaunch();
4040
ImmutableAudience.MobileAttributionContextProvider = () => AttributionContext.Capture();
4141
ImmutableAudience.TrackingAuthorizationRequestProvider = () => ATTBridge.RequestAsync();
42+
ImmutableAudience.MobileATTStatusProvider = () => ATTBridge.GetStatus();
43+
ImmutableAudience.MobileIDFAProvider = () => ATTBridge.GetIDFA();
4244
#endif
4345

4446
#if UNITY_ANDROID && !UNITY_EDITOR

src/Packages/Audience/Runtime/Utility/Log.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,14 @@ internal static string InstallReferrerSentMarkerWriteFailed(Exception ex) =>
157157
$"Failed to write install_referrer_sent marker: {ex.GetType().Name}: {ex.Message}. " +
158158
"install_referrer_received may re-fire on the next launch.";
159159

160+
internal static string ATTStatusProviderThrew(Exception ex) =>
161+
$"MobileATTStatusProvider threw {ex.GetType().Name}: {ex.Message}. " +
162+
"tracking_authorization_changed check skipped.";
163+
164+
internal static string ATTIDFAProviderThrew(Exception ex) =>
165+
$"MobileIDFAProvider threw {ex.GetType().Name}: {ex.Message}. " +
166+
"tracking_authorization_changed will ship without idfa.";
167+
160168
internal static string GAIDFetchThrew(Exception ex) =>
161169
$"GAID fetch threw {ex.GetType().Name}: {ex.Message}. " +
162170
"gaid will not ship on game_launch this session; next launch retries.";

0 commit comments

Comments
 (0)