diff --git a/src/Packages/Audience/Runtime/Audience.Runtime.csproj b/src/Packages/Audience/Runtime/Audience.Runtime.csproj index 7fc7f1620..46635dc4e 100644 --- a/src/Packages/Audience/Runtime/Audience.Runtime.csproj +++ b/src/Packages/Audience/Runtime/Audience.Runtime.csproj @@ -11,6 +11,12 @@ It references UnityEngine, so it cannot build under the headless .NET SDK used for Audience.Tests. Unity's own compiler builds it via Runtime/Unity/com.immutable.audience.unity.asmdef. + + Portability enforcement: com.immutable.audience.asmdef sets + noEngineReferences: true, which makes stray `using UnityEngine` in Core/, + Events/, Transport/, Utility/ fail to compile inside Unity. This + Compile Remove is the sibling check for the headless dotnet build. + Keep both. --> diff --git a/src/Packages/Audience/Runtime/AudienceError.cs b/src/Packages/Audience/Runtime/AudienceError.cs index 3e57ada0b..2d8e07b08 100644 --- a/src/Packages/Audience/Runtime/AudienceError.cs +++ b/src/Packages/Audience/Runtime/AudienceError.cs @@ -4,10 +4,16 @@ namespace Immutable.Audience { public enum AudienceErrorCode { + // An event batch failed to flush. Either a local storage read error (batch dropped) or a non-2xx/non-4xx server response — typically 5xx (batch retained and retried with backoff). FlushFailed, + // Server rejected an event batch with a 4xx status. The batch was dropped; retrying will not help (typically indicates a malformed payload). ValidationRejected, + // Failed to sync a consent change to the backend. The local consent level has already been applied; the server-side audit trail may be out of date. ConsentSyncFailed, - NetworkError + // A network call failed (exception, timeout, or non-2xx response on data deletion). Event batches are retained for retry; data-delete requests are not retried automatically. + NetworkError, + // Failed to persist the consent level to disk. In-memory level still applied but will revert on next launch. + ConsentPersistFailed } public class AudienceError : Exception diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index ae29c3f28..0416d885c 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -39,6 +39,10 @@ public static class ImmutableAudience // PersistentDataPath on the config. internal static Func? DefaultPersistentDataPathProvider; + // AudienceUnityHooks sets this so game_launch can auto-include + // Unity context without the core referencing UnityEngine. + internal static Func>? LaunchContextProvider; + // Starts the SDK. Call once at launch. public static void Init(AudienceConfig config) { @@ -288,14 +292,16 @@ public static void Reset() Identity.Reset(config.PersistentDataPath!); } - // Ask the backend to erase this player's data. - public static void DeleteData(string? userId = null) + // Ask the backend to erase this player's data. Returns a task the + // caller can await to know when the request is acknowledged, or + // discard for fire-and-forget. + public static Task DeleteData(string? userId = null) { - if (!_initialized) return; + if (!_initialized) return Task.CompletedTask; var config = _config; var client = _controlClient; - if (config == null || client == null) return; + if (config == null || client == null) return Task.CompletedTask; string query; if (!string.IsNullOrEmpty(userId)) @@ -307,7 +313,7 @@ public static void DeleteData(string? userId = null) // Get, not GetOrCreate — a brand-new install must not register an ID just to delete it. var anonymousId = Identity.Get(config.PersistentDataPath!); if (string.IsNullOrEmpty(anonymousId)) - return; + return Task.CompletedTask; query = "anonymousId=" + Uri.EscapeDataString(anonymousId); } @@ -316,7 +322,7 @@ public static void DeleteData(string? userId = null) var publishableKey = config.PublishableKey; var cancellationToken = _shutdownCancellationSource?.Token ?? CancellationToken.None; - Task.Run(async () => + return Task.Run(async () => { try { @@ -389,6 +395,8 @@ public static void SetConsent(ConsentLevel level) { Log.Warn($"SetConsent — failed to persist consent level: {ex.GetType().Name}: {ex.Message}. " + "In-memory level is updated but will revert on next launch."); + NotifyErrorCallback(config.OnError, AudienceErrorCode.ConsentPersistFailed, + $"Consent persist failed: {ex.Message}"); } if (level == ConsentLevel.None) @@ -550,6 +558,8 @@ public static void Shutdown() // Shuts down (if initialised) and clears per-session state so a // fresh Init starts clean. Used on test teardown and by Unity // SubsystemRegistration to survive "disable domain reload". + // LaunchContextProvider is not cleared: AudienceUnityHooks + // re-assigns it on the same SubsystemRegistration call. internal static void ResetState() { if (_initialized) @@ -656,10 +666,32 @@ private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAt var properties = new Dictionary(); + // Unity-side auto-detected context (platform, version, buildGuid, + // unityVersion) from AudienceUnityHooks. Core stays pure C#; the + // Unity layer fills these via LaunchContextProvider. + var provider = LaunchContextProvider; + if (provider != null) + { + Dictionary? unityContext = null; + try { unityContext = provider(); } + catch (Exception ex) + { + Log.Warn($"LaunchContextProvider threw {ex.GetType().Name}: {ex.Message}. " + + "game_launch will ship without auto-detected Unity context."); + } + + if (unityContext != null) + { + foreach (var kvp in unityContext) + properties[kvp.Key] = kvp.Value; + } + } + + // Config-supplied distributionPlatform wins over any provider value; + // studios set it explicitly because Unity cannot auto-detect the store. if (config.DistributionPlatform != null) properties["distributionPlatform"] = config.DistributionPlatform; - // Device-derived fields (platform, version, buildGuid, unityVersion) land with DeviceCollector. Track("game_launch", properties.Count > 0 ? properties : null); } } diff --git a/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs b/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs new file mode 100644 index 000000000..188f4485a --- /dev/null +++ b/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs @@ -0,0 +1,35 @@ +#nullable enable + +using System.Collections.Generic; +using UnityEngine; + +namespace Immutable.Audience.Unity +{ + internal static class AudienceUnityHooks + { + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void Install() + { + // Clear surviving statics before re-wiring in case "disable domain reload" kept them alive. + ImmutableAudience.ResetState(); + + // -= then += so repeat SubsystemRegistration cycles don't stack subscriptions. + Application.quitting -= ImmutableAudience.Shutdown; + Application.quitting += ImmutableAudience.Shutdown; + + ImmutableAudience.DefaultPersistentDataPathProvider = () => Application.persistentDataPath; + ImmutableAudience.LaunchContextProvider = BuildLaunchContext; + + if (Log.Writer == null) Log.Writer = Debug.Log; + } + + private static Dictionary BuildLaunchContext() => + new Dictionary + { + ["platform"] = Application.platform.ToString(), + ["version"] = Application.version, + ["buildGuid"] = Application.buildGUID, + ["unityVersion"] = Application.unityVersion, + }; + } +} diff --git a/src/Packages/Audience/Runtime/Unity/com.immutable.audience.unity.asmdef b/src/Packages/Audience/Runtime/Unity/com.immutable.audience.unity.asmdef new file mode 100644 index 000000000..4b186e12c --- /dev/null +++ b/src/Packages/Audience/Runtime/Unity/com.immutable.audience.unity.asmdef @@ -0,0 +1,14 @@ +{ + "name": "Immutable.Audience.Unity", + "rootNamespace": "Immutable.Audience.Unity", + "references": ["Immutable.Audience.Runtime"], + "includePlatforms": ["Editor","LinuxStandalone64","macOSStandalone","WindowsStandalone64"], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/src/Packages/Audience/Runtime/com.immutable.audience.asmdef b/src/Packages/Audience/Runtime/com.immutable.audience.asmdef index 083ac420e..1b3ca3962 100644 --- a/src/Packages/Audience/Runtime/com.immutable.audience.asmdef +++ b/src/Packages/Audience/Runtime/com.immutable.audience.asmdef @@ -10,5 +10,5 @@ "autoReferenced": true, "defineConstraints": [], "versionDefines": [], - "noEngineReferences": false + "noEngineReferences": true } diff --git a/src/Packages/Audience/Tests/Runtime/DeleteDataTests.cs b/src/Packages/Audience/Tests/Runtime/DeleteDataTests.cs index e874512b0..9c930e292 100644 --- a/src/Packages/Audience/Tests/Runtime/DeleteDataTests.cs +++ b/src/Packages/Audience/Tests/Runtime/DeleteDataTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net; using System.Net.Http; using System.Threading; @@ -144,6 +145,35 @@ public void DeleteData_DoesNotCreateAnonymousIdFile() "DeleteData must not create the anonymousId file as a side effect"); } + [Test] + public async Task DeleteData_ReturnsTask_ThatCompletesAfterRequest() + { + var handler = new CapturingHandler(); + ImmutableAudience.Init(MakeConfig(handler)); + + var task = ImmutableAudience.DeleteData(userId: "player-42"); + Assert.IsNotNull(task, "DeleteData must return a non-null Task"); + + // Await directly: no need for the RequestSent gate when the task + // already represents completion. + await task; + + Assert.IsTrue(handler.Requests.Any(r => r.Method == HttpMethod.Delete), + "DELETE request must have been sent by the time the task completes"); + } + + [Test] + public void DeleteData_BeforeInit_ReturnsCompletedTask() + { + // Not initialised — must not throw, must return a completed Task. + ImmutableAudience.ResetState(); + + var task = ImmutableAudience.DeleteData(userId: "player-42"); + + Assert.IsNotNull(task); + Assert.IsTrue(task.IsCompleted, "DeleteData before Init must return an already-completed Task"); + } + [Test] public void DeleteData_ServerError_InvokesOnError() { diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 49250a8fc..62706b7b1 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -26,6 +26,7 @@ public void SetUp() public void TearDown() { ImmutableAudience.ResetState(); + ImmutableAudience.LaunchContextProvider = null; ImmutableAudience.DefaultPersistentDataPathProvider = null; Identity.Reset(_testDir); if (Directory.Exists(_testDir)) @@ -712,6 +713,30 @@ public void ResetState_ClearsIdentityCache_AcrossInitWithDifferentPath() } } + [Test] + public void SetConsent_PersistFailure_SurfacesOnError() + { + // Pre-create a directory where ConsentStore.Save wants to place + // the consent file; File.Move then fails without disturbing + // Init's DiskStore or Identity paths. + var consentFile = AudiencePaths.ConsentFile(_testDir); + Directory.CreateDirectory(consentFile); + + // Bag rather than single capture: ConsentPersistFailed fires + // synchronously on the caller thread, SyncConsentToBackend's + // Task.Run may also fire ConsentSyncFailed concurrently. Assert + // presence of the one under test rather than the last seen. + var errors = new System.Collections.Concurrent.ConcurrentBag(); + var config = MakeConfig(ConsentLevel.Anonymous); + config.OnError = err => errors.Add(err); + + ImmutableAudience.Init(config); + ImmutableAudience.SetConsent(ConsentLevel.Full); + + Assert.That(errors.Any(e => e.Code == AudienceErrorCode.ConsentPersistFailed), + Is.True, "OnError should receive ConsentPersistFailed for consent persist failure"); + } + [Test] public void SetConsent_PersistsAcrossInit() { @@ -776,6 +801,69 @@ public void Init_ConsentNone_DoesNotFireGameLaunch() Assert.IsFalse(contents.Any(c => c.Contains("\"game_launch\""))); } + [Test] + public void Init_GameLaunch_IncludesLaunchContextProviderFields() + { + ImmutableAudience.LaunchContextProvider = () => new Dictionary + { + ["platform"] = "WindowsPlayer", + ["version"] = "1.2.3", + ["buildGuid"] = "a1b2c3d4e5f6", + ["unityVersion"] = "2022.3.20f1", + }; + + ImmutableAudience.Init(MakeConfig()); + ImmutableAudience.Shutdown(); + + var queueDir = AudiencePaths.QueueDir(_testDir); + var launchFile = Directory.GetFiles(queueDir, "*.json") + .Select(File.ReadAllText) + .FirstOrDefault(c => c.Contains("\"game_launch\"")); + Assert.IsNotNull(launchFile, "game_launch should have been enqueued"); + StringAssert.Contains("\"platform\":\"WindowsPlayer\"", launchFile); + StringAssert.Contains("\"version\":\"1.2.3\"", launchFile); + StringAssert.Contains("\"buildGuid\":\"a1b2c3d4e5f6\"", launchFile); + StringAssert.Contains("\"unityVersion\":\"2022.3.20f1\"", launchFile); + } + + [Test] + public void Init_GameLaunch_ConfigDistributionPlatformOverridesProvider() + { + ImmutableAudience.LaunchContextProvider = () => new Dictionary + { + ["distributionPlatform"] = "provider_value", + }; + + var config = MakeConfig(); + config.DistributionPlatform = DistributionPlatforms.Steam; + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + var queueDir = AudiencePaths.QueueDir(_testDir); + var launchFile = Directory.GetFiles(queueDir, "*.json") + .Select(File.ReadAllText) + .First(c => c.Contains("\"game_launch\"")); + StringAssert.Contains("\"distributionPlatform\":\"steam\"", launchFile); + Assert.IsFalse(launchFile.Contains("provider_value"), + "config.DistributionPlatform should win over the provider's value"); + } + + [Test] + public void Init_GameLaunch_ProviderThrows_StillFiresEvent() + { + ImmutableAudience.LaunchContextProvider = () => + throw new InvalidOperationException("provider exploded"); + + Assert.DoesNotThrow(() => ImmutableAudience.Init(MakeConfig())); + ImmutableAudience.Shutdown(); + + var queueDir = AudiencePaths.QueueDir(_testDir); + var contents = Directory.GetFiles(queueDir, "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsTrue(contents.Any(c => c.Contains("\"game_launch\"")), + "game_launch must still ship when the context provider throws"); + } + // ----------------------------------------------------------------- // Shutdown // ----------------------------------------------------------------- diff --git a/src/Packages/Audience/link.xml b/src/Packages/Audience/link.xml new file mode 100644 index 000000000..456c9d2a3 --- /dev/null +++ b/src/Packages/Audience/link.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/Packages/Audience/link.xml.meta b/src/Packages/Audience/link.xml.meta new file mode 100644 index 000000000..cc28ec6c2 --- /dev/null +++ b/src/Packages/Audience/link.xml.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8a4d2f7e1c9b4a5d9e0f1c2d3b4e5f60 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: