diff --git a/src/Packages/Audience/Runtime/Core/AttStatusStore.cs b/src/Packages/Audience/Runtime/Core/AttStatusStore.cs new file mode 100644 index 000000000..c1d2e0547 --- /dev/null +++ b/src/Packages/Audience/Runtime/Core/AttStatusStore.cs @@ -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; + } + } +} diff --git a/src/Packages/Audience/Runtime/Core/AttStatusStore.cs.meta b/src/Packages/Audience/Runtime/Core/AttStatusStore.cs.meta new file mode 100644 index 000000000..7a3b7cc1c --- /dev/null +++ b/src/Packages/Audience/Runtime/Core/AttStatusStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a7e1b2c3d4f5a6b7c8d9e0f1a2b3c4d5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Runtime/Core/AudiencePaths.cs b/src/Packages/Audience/Runtime/Core/AudiencePaths.cs index 82b20f58c..5692a754e 100644 --- a/src/Packages/Audience/Runtime/Core/AudiencePaths.cs +++ b/src/Packages/Audience/Runtime/Core/AudiencePaths.cs @@ -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); @@ -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); } } diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index e79a39b05..7b5237433 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -70,6 +70,16 @@ public static class ImmutableAudience // layer; null in pure-C# environments and on non-Android platforms. internal static volatile Func? 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? 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? 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. @@ -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 @@ -774,6 +786,11 @@ public static async Task 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; } @@ -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 + { + ["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); + } } } diff --git a/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs b/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs index 1699e2d08..c4c79838f 100644 --- a/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs +++ b/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs @@ -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 diff --git a/src/Packages/Audience/Runtime/Utility/Log.cs b/src/Packages/Audience/Runtime/Utility/Log.cs index 222c2ca73..a9d77a7d8 100644 --- a/src/Packages/Audience/Runtime/Utility/Log.cs +++ b/src/Packages/Audience/Runtime/Utility/Log.cs @@ -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."; diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 70894976e..4a6ed38c5 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -33,6 +33,8 @@ public void TearDown() ImmutableAudience.MobileAttributionContextProvider = null; ImmutableAudience.TrackingAuthorizationRequestProvider = null; ImmutableAudience.MobileInstallReferrerProvider = null; + ImmutableAudience.MobileATTStatusProvider = null; + ImmutableAudience.MobileIDFAProvider = null; Identity.Reset(_testDir); if (Directory.Exists(_testDir)) Directory.Delete(_testDir, recursive: true); @@ -2055,5 +2057,177 @@ protected override async Task SendAsync(HttpRequestMessage return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); } } + + // ----------------------------------------------------------------- + // tracking_authorization_changed + // + // Fires when iOS ATT status transitions to a different value than the + // last-persisted observation. Requires EnableMobileAttribution and + // CanTrack(). idfa is included only when transitioning to authorized + // AND consent is Full. + // ----------------------------------------------------------------- + + [Test] + public void Init_FiresAttStatusChanged_WhenStatusTransitionsFromCached() + { + // Seed: previous launch observed denied (2); this launch is authorized (3). + AttStatusStore.Save(_testDir, 2); + ImmutableAudience.MobileATTStatusProvider = () => 3; + ImmutableAudience.MobileIDFAProvider = () => "11111111-2222-3333-4444-555555555555"; + var config = MakeConfig(ConsentLevel.Full); + config.EnableMobileAttribution = true; + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + var blobs = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsTrue(blobs.Any(b => + b.Contains("\"tracking_authorization_changed\"") && + b.Contains("\"previousStatus\":\"denied\"") && + b.Contains("\"newStatus\":\"authorized\"") && + b.Contains("\"idfa\":\"11111111-2222-3333-4444-555555555555\"")), + "Init must fire tracking_authorization_changed when ATT status transitions"); + } + + [Test] + public void Init_DoesNotFireAttStatusChanged_WhenStatusUnchanged() + { + AttStatusStore.Save(_testDir, 3); + ImmutableAudience.MobileATTStatusProvider = () => 3; + var config = MakeConfig(ConsentLevel.Full); + config.EnableMobileAttribution = true; + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + var blobs = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsFalse(blobs.Any(b => b.Contains("\"tracking_authorization_changed\"")), + "no event when ATT status has not changed"); + } + + [Test] + public void Init_StoresAttStatusBaseline_WithoutFiringEvent_OnFirstObservation() + { + // No prior file: first observation establishes the baseline; no event fires + // because there is no previous state to transition from. + ImmutableAudience.MobileATTStatusProvider = () => 2; // denied + var config = MakeConfig(ConsentLevel.Anonymous); + config.EnableMobileAttribution = true; + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + var blobs = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsFalse(blobs.Any(b => b.Contains("\"tracking_authorization_changed\"")), + "first observation must not fire tracking_authorization_changed"); + Assert.AreEqual(2, AttStatusStore.Load(_testDir), + "first observation must persist the baseline status"); + } + + [Test] + public async Task RequestTrackingAuthorizationAsync_FiresAttStatusChanged_WhenStatusTransitions() + { + AttStatusStore.Save(_testDir, 0); // notDetermined + ImmutableAudience.TrackingAuthorizationRequestProvider = () => Task.FromResult(3); // authorized + ImmutableAudience.MobileIDFAProvider = () => "11111111-2222-3333-4444-555555555555"; + var config = MakeConfig(ConsentLevel.Full); + config.EnableMobileAttribution = true; + ImmutableAudience.Init(config); + + ImmutableAudience.FlushQueueToDiskForTesting(); + var queueDir = AudiencePaths.QueueDir(_testDir); + foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + + await ImmutableAudience.RequestTrackingAuthorizationAsync(); + ImmutableAudience.Shutdown(); + + var blobs = Directory.GetFiles(queueDir, "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsTrue(blobs.Any(b => + b.Contains("\"tracking_authorization_changed\"") && + b.Contains("\"previousStatus\":\"notDetermined\"") && + b.Contains("\"newStatus\":\"authorized\"") && + b.Contains("\"idfa\":\"11111111-2222-3333-4444-555555555555\"")), + "RequestTrackingAuthorizationAsync must fire tracking_authorization_changed when status transitions"); + } + + [Test] + public async Task RequestTrackingAuthorizationAsync_DoesNotFireAttStatusChanged_WhenStatusUnchanged() + { + AttStatusStore.Save(_testDir, 3); // already authorized + ImmutableAudience.TrackingAuthorizationRequestProvider = () => Task.FromResult(3); + var config = MakeConfig(ConsentLevel.Full); + config.EnableMobileAttribution = true; + ImmutableAudience.Init(config); + + ImmutableAudience.FlushQueueToDiskForTesting(); + var queueDir = AudiencePaths.QueueDir(_testDir); + foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + + await ImmutableAudience.RequestTrackingAuthorizationAsync(); + ImmutableAudience.Shutdown(); + + var blobs = Directory.GetFiles(queueDir, "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsFalse(blobs.Any(b => b.Contains("\"tracking_authorization_changed\"")), + "no event when ATT status has not changed"); + } + + [Test] + public void AttStatusChanged_StripsIdfa_WhenConsentAnonymous() + { + // Transition to authorized at Anonymous: event fires (non-identifying state), + // but idfa is Full-only and must not appear. + AttStatusStore.Save(_testDir, 2); // denied -> authorized + ImmutableAudience.MobileATTStatusProvider = () => 3; + ImmutableAudience.MobileIDFAProvider = () => "11111111-2222-3333-4444-555555555555"; + var config = MakeConfig(ConsentLevel.Anonymous); + config.EnableMobileAttribution = true; + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + var blobs = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json") + .Select(File.ReadAllText).ToList(); + var changeEvent = blobs.FirstOrDefault(b => b.Contains("\"tracking_authorization_changed\"")); + Assert.IsNotNull(changeEvent, "event must fire at Anonymous consent"); + Assert.IsFalse(changeEvent!.Contains("\"idfa\""), + "idfa must not ship at Anonymous: it is a cross-app device identifier"); + } + + [Test] + public void AttStatusChanged_NotFired_WhenConsentNone() + { + AttStatusStore.Save(_testDir, 0); + ImmutableAudience.MobileATTStatusProvider = () => 3; + var config = MakeConfig(ConsentLevel.None); + config.EnableMobileAttribution = true; + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + var queueDir = AudiencePaths.QueueDir(_testDir); + if (!Directory.Exists(queueDir)) return; + var blobs = Directory.GetFiles(queueDir, "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsFalse(blobs.Any(b => b.Contains("\"tracking_authorization_changed\"")), + "tracking_authorization_changed must not fire when consent is None"); + } + + [Test] + public void AttStatusChanged_NotFired_WhenEnableMobileAttributionFalse() + { + AttStatusStore.Save(_testDir, 0); + var providerCallCount = 0; + ImmutableAudience.MobileATTStatusProvider = () => { providerCallCount++; return 3; }; + var config = MakeConfig(ConsentLevel.Full); + config.EnableMobileAttribution = false; + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + Assert.AreEqual(0, providerCallCount, + "MobileATTStatusProvider must not be called when EnableMobileAttribution is false"); + var blobs = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsFalse(blobs.Any(b => b.Contains("\"tracking_authorization_changed\""))); + } } } \ No newline at end of file