From 62571ffe06958dab2de4a970d5f1fff482e0cb9a Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 22 Apr 2026 03:39:50 +1000 Subject: [PATCH 1/7] feat(audience): add Unity integration layer and pure-C# core partition (SDK-147) Splits the package into a core asmdef (pure C#, buildable headless under dotnet) and a Unity sub-asmdef (engine-referencing). The Unity layer has always belonged here; the fence on the core was always correct; landing both together makes the partition enforceable at Unity-compile time. - Runtime/Unity/AudienceUnityHooks.cs: RuntimeInitializeOnLoadMethod wiring. Flushes on pause/quit via Application hooks. Installs Debug.Log as Log.Writer for SDK diagnostics. - Runtime/Unity/com.immutable.audience.unity.asmdef: sibling sub-asmdef claiming Runtime/Unity/. Excludes unsupported platforms (mobile, consoles) per v1 scope (Windows / macOS / Linux). Referenced from the core asmdef's sibling - not the other way around, keeping the dependency direction clean. - Runtime/com.immutable.audience.asmdef: noEngineReferences flipped to true. The core has always been pure C# (proven by the sibling Audience.Runtime.csproj that compiles the same tree headless), but the flag shipped as Unity's Editor-default false. Now that the Unity sub-asmdef exists to hold UnityEngine-using code, flipping the fence makes the guarantee enforceable at Unity-compile time - stray `using UnityEngine` in Core/, Events/, Transport/, or Utility/ now breaks the Unity build, matching what dotnet already rejects. - Audience.Runtime.csproj: comment now cross-references the asmdef flag as the primary portability fence and the Compile Remove as the sibling check for the headless dotnet build. Prevents a future cleanup from removing one of the two halves. --- .../Audience/Runtime/Audience.Runtime.csproj | 6 +++++ .../Runtime/Unity/AudienceUnityHooks.cs | 22 +++++++++++++++++++ .../Unity/com.immutable.audience.unity.asmdef | 14 ++++++++++++ .../Runtime/com.immutable.audience.asmdef | 2 +- 4 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs create mode 100644 src/Packages/Audience/Runtime/Unity/com.immutable.audience.unity.asmdef 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/Unity/AudienceUnityHooks.cs b/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs new file mode 100644 index 000000000..594163567 --- /dev/null +++ b/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs @@ -0,0 +1,22 @@ +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; + + if (Log.Writer == null) Log.Writer = Debug.Log; + } + } +} 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 } From 5fd9c5a5a5576c949902a54114181c32a054fe4a Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 22 Apr 2026 05:32:57 +1000 Subject: [PATCH 2/7] build(audience): add IL2CPP link.xml for Unity ship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §6.6 requires a link.xml at the package root so IL2CPP's managed-code stripping doesn't remove types the SDK reaches only at runtime. Preserves the full Immutable.Audience.Runtime assembly and names the System.Net.Http pipeline pieces used by HttpTransport / SetConsent / DeleteData. System.IO.Compression entries are harmless when the gzip scripting define is off. --- src/Packages/Audience/link.xml | 31 +++++++++++++++++++++++++++++ src/Packages/Audience/link.xml.meta | 7 +++++++ 2 files changed, 38 insertions(+) create mode 100644 src/Packages/Audience/link.xml create mode 100644 src/Packages/Audience/link.xml.meta 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: From 2af6141cc423d7c6c9642475fbcdccb94c56d2ed Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 22 Apr 2026 00:47:50 +1000 Subject: [PATCH 3/7] feat(audience): auto-include Unity context on game_launch (SDK-147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §5.1 and the Event Reference require game_launch to ship with platform, version, buildGuid, unityVersion auto-detected. Until the Day 4 DeviceCollector lands, game_launch was shipping with only the studio-supplied distributionPlatform. - ImmutableAudience gets a new internal seam, LaunchContextProvider, that returns a Dictionary merged into game_launch properties. Core stays pure C# - the provider is installed from the Unity layer so no UnityEngine import leaks into Runtime/. - FireGameLaunch wraps the provider call in try/catch with a clear warning. A buggy provider must never prevent the launch event from firing - game_launch is the most load-bearing attribution event. - AudienceUnityHooks installs a default provider that reads Application.platform, Application.version, Application.buildGUID, Application.unityVersion. DeviceCollector (Day 4) can replace or extend this without re-wiring. - config.DistributionPlatform keeps winning over any provider value: studios set it explicitly because Unity cannot auto-detect the distribution store, and that value is the one the attribution pipeline expects. Three new tests: provider fields make it onto the event, config overrides provider for distributionPlatform, and a throwing provider doesn't skip the event. 154 passing. Linear: SDK-147 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Audience/Runtime/ImmutableAudience.cs | 30 ++++++++- .../Runtime/Unity/AudienceUnityHooks.cs | 11 ++++ .../Tests/Runtime/ImmutableAudienceTests.cs | 64 +++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index ae29c3f28..f3d07ddee 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) { @@ -550,6 +554,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 +662,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 index 594163567..040997d03 100644 --- a/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs +++ b/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using UnityEngine; namespace Immutable.Audience.Unity @@ -15,8 +16,18 @@ private static void Install() 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/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 49250a8fc..7d382ddee 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)) @@ -776,6 +777,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 // ----------------------------------------------------------------- From d9f7ca6be715830439b51aa9b01deca6fc46d586 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 22 Apr 2026 00:50:00 +1000 Subject: [PATCH 4/7] feat(audience): surface consent persistence failures via OnError (SDK-147) ConsentStore.Save can fail from transient I/O (disk full, locked file, permissions). Previously the catch handler at ImmutableAudience.cs:318 logged a warning and returned silently - studios had no programmatic signal that their consent change wouldn't survive a restart. Adds AudienceErrorCode.ConsentPersistFailed, distinct from ConsentSyncFailed (backend PUT) so operators can tell the two failure modes apart: a Save failure means local state will revert on relaunch, a Sync failure means the backend audit trail is out of sync but local state is fine. In-memory behaviour is unchanged - the new level still applies to the current session, the purge/downgrade still runs. The callback just gives studios a hook to warn the player, log to their telemetry, or retry the persist at a better moment. New test pre-creates a directory at the consent file path to force File.Move to fail, then asserts ConsentPersistFailed reaches OnError. 155 passing. Linear: SDK-147 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Audience/Runtime/AudienceError.cs | 4 +++- .../Audience/Runtime/ImmutableAudience.cs | 2 ++ .../Tests/Runtime/ImmutableAudienceTests.cs | 24 +++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/Packages/Audience/Runtime/AudienceError.cs b/src/Packages/Audience/Runtime/AudienceError.cs index 3e57ada0b..2d41349ad 100644 --- a/src/Packages/Audience/Runtime/AudienceError.cs +++ b/src/Packages/Audience/Runtime/AudienceError.cs @@ -7,7 +7,9 @@ public enum AudienceErrorCode FlushFailed, ValidationRejected, ConsentSyncFailed, - NetworkError + 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 f3d07ddee..ca4bb6e33 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -393,6 +393,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) diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 7d382ddee..62706b7b1 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -713,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() { From 3c82d51012a49a476646a1aecc61281be6fa8dc4 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 22 Apr 2026 00:54:33 +1000 Subject: [PATCH 5/7] refactor(audience): return Task from DeleteData (SDK-147) DeleteData fires a backend DELETE for GDPR erasure but returned void, so callers had no handle to know when the erasure completed or failed. FlushAsync returns Task for the same reason - symmetrising the lifecycle methods on the singleton. - Public signature becomes `public static Task DeleteData(string userId = null)`. - All early-return paths (not initialised, no config, no anonymousId) return Task.CompletedTask. - The hot path's Task.Run is just returned directly instead of discarded. No change to the actual HTTP request, error surfacing, or anonymousId-file-side-effect-avoidance contract. - Callers that want fire-and-forget still get it by ignoring the return value; callers that want to gate on completion can now await. Existing void-discarding call sites continue to compile. Two new tests: awaited task completes after DELETE dispatch, and the pre-Init guard returns Task.CompletedTask. 158 passing. Linear: SDK-147 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Audience/Runtime/ImmutableAudience.cs | 14 +++++---- .../Audience/Tests/Runtime/DeleteDataTests.cs | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index ca4bb6e33..51fbbe213 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -292,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)) @@ -311,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); } @@ -320,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 { 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() { From 7067bbac51cc805f3a3a867831f0e46b06d2b60e Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 22 Apr 2026 10:13:42 +1000 Subject: [PATCH 6/7] refactor(audience): enable nullable reference types on Slice 4 files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Runtime/Unity/AudienceUnityHooks.cs: directive only (all references come from UnityEngine which is typed conservatively). - ImmutableAudience.cs: LaunchContextProvider field (added by the auto-context commit on top of the already-annotated file) → nullable; unityContext local in FireGameLaunch → nullable (receives provider result that may be null on exception). Compile-time annotations only; zero runtime behaviour change. --- src/Packages/Audience/Runtime/ImmutableAudience.cs | 4 ++-- src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 51fbbe213..0416d885c 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -41,7 +41,7 @@ public static class ImmutableAudience // AudienceUnityHooks sets this so game_launch can auto-include // Unity context without the core referencing UnityEngine. - internal static Func> LaunchContextProvider; + internal static Func>? LaunchContextProvider; // Starts the SDK. Call once at launch. public static void Init(AudienceConfig config) @@ -672,7 +672,7 @@ private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAt var provider = LaunchContextProvider; if (provider != null) { - Dictionary unityContext = null; + Dictionary? unityContext = null; try { unityContext = provider(); } catch (Exception ex) { diff --git a/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs b/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs index 040997d03..188f4485a 100644 --- a/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs +++ b/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs @@ -1,3 +1,5 @@ +#nullable enable + using System.Collections.Generic; using UnityEngine; From 785811551a473730c820ca414016b5e3a93ba3e4 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 22 Apr 2026 18:26:41 +1000 Subject: [PATCH 7/7] docs(audience): comment all AudienceErrorCode values (SDK-147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FlushFailed: local storage read error (batch dropped), or non-2xx/non-4xx response — typically 5xx (batch retained, retried with backoff). - ValidationRejected: server 4xx rejects batch; dropped, not retried. - ConsentSyncFailed: backend PUT /tracking-consent failed; local state already applied. - NetworkError: HTTP exception, timeout, or non-2xx on data deletion. - ConsentPersistFailed: converted from /// to // to match the rest of the codebase. Wording derived from raise-sites in HttpTransport.cs and ImmutableAudience.cs. Addresses nattb8's review comment on PR #695. Linear: SDK-147 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Packages/Audience/Runtime/AudienceError.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Packages/Audience/Runtime/AudienceError.cs b/src/Packages/Audience/Runtime/AudienceError.cs index 2d41349ad..2d8e07b08 100644 --- a/src/Packages/Audience/Runtime/AudienceError.cs +++ b/src/Packages/Audience/Runtime/AudienceError.cs @@ -4,11 +4,15 @@ 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, + // 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. + // Failed to persist the consent level to disk. In-memory level still applied but will revert on next launch. ConsentPersistFailed }