From 7c015ab49eea5fb04c09ba4c389ae6194441c33d Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Tue, 21 Apr 2026 18:01:25 +1000 Subject: [PATCH 1/5] feat(audience): gate gzip transport behind IMMUTABLE_AUDIENCE_GZIP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan ยง6.7 has v1 shipping plain UTF-8 JSON because platform-services/services/audience does not yet decompress Content-Encoding: gzip on POST /v1/audience/messages. Sending a gzipped body today returns 400 for every batch. Guards the gzip path, the Gzip utility, and the gzip-specific tests behind the scripting define IMMUTABLE_AUDIENCE_GZIP. Default (flag off) sends plain JSON with no Content-Encoding header. Flip the define on once the backend middleware lands. - HttpTransport: wraps the gzip branch in #if; the default branch uses StringContent plain JSON. - Gzip.cs and GzipTests.cs compile only under the flag. - HttpTransportTests: gzip assertion tightened to check Content-Encoding. Adds a default-path test asserting no Content-Encoding is set and the body parses as plain JSON. Verified: - dotnet test: 151 passed (default, gzip off) - dotnet test -p:DefineConstants=IMMUTABLE_AUDIENCE_GZIP: 154 passed Linear: SDK-147 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Runtime/Transport/HttpTransport.cs | 7 +++- src/Packages/Audience/Runtime/Utility/Gzip.cs | 4 +- .../Audience/Tests/Runtime/GzipTests.cs | 4 +- .../Tests/Runtime/HttpTransportTests.cs | 38 +++++++++++++++++++ 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs index 093d15c65..999b245ca 100644 --- a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs +++ b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs @@ -80,15 +80,18 @@ internal async Task SendBatchAsync(CancellationToken ct = default) return true; } - var compressed = Gzip.Compress(payload); - try { using var request = new HttpRequestMessage(HttpMethod.Post, _url); request.Headers.Add("x-immutable-publishable-key", _publishableKey); +#if IMMUTABLE_AUDIENCE_GZIP + var compressed = Gzip.Compress(payload); request.Content = new ByteArrayContent(compressed); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); request.Content.Headers.Add("Content-Encoding", "gzip"); +#else + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); +#endif using var response = await _client.SendAsync(request, ct).ConfigureAwait(false); diff --git a/src/Packages/Audience/Runtime/Utility/Gzip.cs b/src/Packages/Audience/Runtime/Utility/Gzip.cs index c54d4cd33..c4d2e88e4 100644 --- a/src/Packages/Audience/Runtime/Utility/Gzip.cs +++ b/src/Packages/Audience/Runtime/Utility/Gzip.cs @@ -1,3 +1,4 @@ +#if IMMUTABLE_AUDIENCE_GZIP using System.IO; using System.IO.Compression; using System.Text; @@ -23,4 +24,5 @@ internal static byte[] Compress(string text) return output.ToArray(); } } -} \ No newline at end of file +} +#endif diff --git a/src/Packages/Audience/Tests/Runtime/GzipTests.cs b/src/Packages/Audience/Tests/Runtime/GzipTests.cs index 8d509be44..074dd21f8 100644 --- a/src/Packages/Audience/Tests/Runtime/GzipTests.cs +++ b/src/Packages/Audience/Tests/Runtime/GzipTests.cs @@ -1,3 +1,4 @@ +#if IMMUTABLE_AUDIENCE_GZIP using System.IO; using System.IO.Compression; using System.Text; @@ -56,4 +57,5 @@ public void Compress_EmptyString_ProducesValidGzip() Assert.AreEqual("", decompressed); } } -} \ No newline at end of file +} +#endif diff --git a/src/Packages/Audience/Tests/Runtime/HttpTransportTests.cs b/src/Packages/Audience/Tests/Runtime/HttpTransportTests.cs index 8e5f18620..2d1ff4d2a 100644 --- a/src/Packages/Audience/Tests/Runtime/HttpTransportTests.cs +++ b/src/Packages/Audience/Tests/Runtime/HttpTransportTests.cs @@ -1,6 +1,8 @@ using System; using System.IO; +#if IMMUTABLE_AUDIENCE_GZIP using System.IO.Compression; +#endif using System.Net; using System.Net.Http; using System.Text; @@ -57,6 +59,7 @@ public async Task SendBatchAsync_200_DeletesFilesFromDisk() Assert.AreEqual(0, _store.Count(), "files should be deleted after 200"); } +#if IMMUTABLE_AUDIENCE_GZIP [Test] public async Task SendBatchAsync_200_SendsGzippedPayloadWithCorrectHeaders() { @@ -65,12 +68,14 @@ public async Task SendBatchAsync_200_SendsGzippedPayloadWithCorrectHeaders() byte[] capturedBody = null; string capturedKey = null; string capturedContentType = null; + string capturedContentEncoding = null; // Read body inside the callback โ€” the request content is disposed after SendAsync returns. var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}", onRequest: req => { capturedKey = string.Join("", req.Headers.GetValues("x-immutable-publishable-key")); capturedContentType = req.Content.Headers.ContentType.MediaType; + capturedContentEncoding = string.Join("", req.Content.Headers.ContentEncoding); capturedBody = req.Content.ReadAsByteArrayAsync().Result; }); using var transport = new HttpTransport(_store, "pk_imapik-test-key1", handler: handler); @@ -79,12 +84,43 @@ public async Task SendBatchAsync_200_SendsGzippedPayloadWithCorrectHeaders() Assert.AreEqual("pk_imapik-test-key1", capturedKey); Assert.AreEqual("application/json", capturedContentType); + Assert.AreEqual("gzip", capturedContentEncoding); var decompressed = DecompressGzip(capturedBody); StringAssert.StartsWith("{\"batch\":[", decompressed); StringAssert.EndsWith("]}", decompressed); StringAssert.Contains("\"eventName\":\"test\"", decompressed); } +#else + [Test] + public async Task SendBatchAsync_200_SendsPlainJsonPayloadWithoutContentEncoding() + { + _store.Write("{\"type\":\"track\",\"eventName\":\"test\"}"); + + string capturedKey = null; + string capturedContentType = null; + int capturedContentEncodingCount = -1; + string capturedBody = null; + var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}", + onRequest: req => + { + capturedKey = string.Join("", req.Headers.GetValues("x-immutable-publishable-key")); + capturedContentType = req.Content.Headers.ContentType.MediaType; + capturedContentEncodingCount = req.Content.Headers.ContentEncoding.Count; + capturedBody = req.Content.ReadAsStringAsync().Result; + }); + using var transport = new HttpTransport(_store, "pk_imapik-test-key1", handler: handler); + + await transport.SendBatchAsync(); + + Assert.AreEqual("pk_imapik-test-key1", capturedKey); + Assert.AreEqual("application/json", capturedContentType); + Assert.AreEqual(0, capturedContentEncodingCount, "no Content-Encoding header is permitted in v1"); + StringAssert.StartsWith("{\"batch\":[", capturedBody); + StringAssert.EndsWith("]}", capturedBody); + StringAssert.Contains("\"eventName\":\"test\"", capturedBody); + } +#endif [Test] public async Task SendBatchAsync_200_UsesCorrectUrlForTestKey() @@ -342,6 +378,7 @@ public async Task SendBatchAsync_ErrorCallbackThrows_DoesNotCrash() Assert.DoesNotThrowAsync(() => transport.SendBatchAsync()); } +#if IMMUTABLE_AUDIENCE_GZIP private static string DecompressGzip(byte[] data) { using var input = new MemoryStream(data); @@ -349,6 +386,7 @@ private static string DecompressGzip(byte[] data) using var reader = new StreamReader(gzip, Encoding.UTF8); return reader.ReadToEnd(); } +#endif /// /// Minimal HttpMessageHandler that returns a canned response. From 0fb9eb5d68e75adb852ca260ee1e036b3e8bf3e0 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 22 Apr 2026 03:56:14 +1000 Subject: [PATCH 2/5] docs(audience): switch to plain // comments across the package Converts the remaining XML doc comments in the already-merged SDK pieces (DiskStore, EventQueue, HttpTransport, Gzip, Constants, ConsentLevel, AudienceConfig, ImmutableAudience scaffold, and the transport tests) to plain // comments. Brings them in line with the style used by the incoming SDK-147 commits. No behaviour change. --- .../Audience/Runtime/AudienceConfig.cs | 17 +++++--- src/Packages/Audience/Runtime/ConsentLevel.cs | 8 ++-- .../Audience/Runtime/Core/Constants.cs | 5 +-- .../Audience/Runtime/ImmutableAudience.cs | 7 +--- .../Audience/Runtime/Transport/DiskStore.cs | 18 ++++---- .../Audience/Runtime/Transport/EventQueue.cs | 36 ++++++---------- .../Runtime/Transport/HttpTransport.cs | 41 ++++++------------- src/Packages/Audience/Runtime/Utility/Gzip.cs | 6 +-- .../Tests/Runtime/HttpTransportTests.cs | 6 +-- 9 files changed, 56 insertions(+), 88 deletions(-) diff --git a/src/Packages/Audience/Runtime/AudienceConfig.cs b/src/Packages/Audience/Runtime/AudienceConfig.cs index 759645562..f6085bca1 100644 --- a/src/Packages/Audience/Runtime/AudienceConfig.cs +++ b/src/Packages/Audience/Runtime/AudienceConfig.cs @@ -1,17 +1,24 @@ namespace Immutable.Audience { - /// Configuration passed to . + // Configuration passed to ImmutableAudience.Init. public class AudienceConfig { + // Studio API key. public string PublishableKey { get; set; } + + // Initial consent level. public ConsentLevel Consent { get; set; } = ConsentLevel.None; - /// - /// Distribution platform the game is running on. - /// Use for common values, or pass any custom string. - /// + + // Distribution platform the game is running on. public string DistributionPlatform { get; set; } + + // Enable debug logging. public bool Debug { get; set; } = false; + + // How often pending events are flushed to the backend. public int FlushIntervalSeconds { get; set; } = Constants.DefaultFlushIntervalSeconds; + + // Flush as soon as this many events are queued. public int FlushSize { get; set; } = Constants.DefaultFlushSize; } } diff --git a/src/Packages/Audience/Runtime/ConsentLevel.cs b/src/Packages/Audience/Runtime/ConsentLevel.cs index 66e518c05..197836d75 100644 --- a/src/Packages/Audience/Runtime/ConsentLevel.cs +++ b/src/Packages/Audience/Runtime/ConsentLevel.cs @@ -1,13 +1,13 @@ namespace Immutable.Audience { - /// Controls what the Audience SDK tracks. + // How much data the Audience SDK is allowed to collect. public enum ConsentLevel { - /// SDK inert. No events queued or sent. No IDs persisted to disk. + // No tracking. None, - /// Track events with anonymousId only. Identify/Alias discarded with warning. + // Anonymous tracking only. Anonymous, - /// All events. Identify/Alias send. userId attached to track events. + // Full tracking, including identity. Full } } diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index 6573730d8..a7ed09792 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -25,10 +25,7 @@ internal static string BaseUrl(string publishableKey) => : ProductionBaseUrl; } - /// - /// String constants for common game distribution platforms. - /// Any string is accepted -- studios are not limited to these values. - /// + // Common distribution platform values for AudienceConfig.DistributionPlatform. public static class DistributionPlatforms { public const string Steam = "steam"; diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 6c882b7b7..d8b78aed2 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -1,14 +1,11 @@ namespace Immutable.Audience { - /// - /// Entry point for the Immutable Audience SDK. - /// Call once on startup, then use the static methods from any thread. - /// + // Entry point for the Immutable Audience SDK. public static class ImmutableAudience { // Scaffold only -- implementation follows in subsequent sub-issues (see SDK-99). - /// Initialise the SDK. Call once, typically in your game's entry scene. + // Starts the SDK. Call once at launch. public static void Init(AudienceConfig config) { throw new System.NotImplementedException( diff --git a/src/Packages/Audience/Runtime/Transport/DiskStore.cs b/src/Packages/Audience/Runtime/Transport/DiskStore.cs index 6e691521b..e57e09137 100644 --- a/src/Packages/Audience/Runtime/Transport/DiskStore.cs +++ b/src/Packages/Audience/Runtime/Transport/DiskStore.cs @@ -5,10 +5,8 @@ namespace Immutable.Audience { - /// - /// File-per-event persistent store. Each event is written as an atomic - /// {ticks}_{uuid}.json file inside imtbl_audience/queue/. - /// + // File-per-event persistent store. Each event is written as an atomic + // {ticks}_{uuid}.json file inside imtbl_audience/queue/. internal sealed class DiskStore { private readonly string _queueDir; @@ -19,7 +17,7 @@ internal DiskStore(string persistentDataPath) Directory.CreateDirectory(_queueDir); } - /// Atomically writes as a new event file. + // Atomically writes json as a new event file. internal void Write(string json) { var fileName = $"{DateTime.UtcNow.Ticks}_{Guid.NewGuid():N}.json"; @@ -40,10 +38,8 @@ internal void Write(string json) } } - /// - /// Returns up to file paths, oldest first. - /// Files older than days are deleted and excluded. - /// + // Returns up to maxSize file paths, oldest first. Stale files + // (older than Constants.StaleEventDays) are deleted and excluded. internal IReadOnlyList ReadBatch(int maxSize) { if (maxSize <= 0) @@ -83,14 +79,14 @@ internal IReadOnlyList ReadBatch(int maxSize) return result; } - /// Deletes the event files at . + // Deletes the given event files. internal void Delete(IEnumerable paths) { foreach (var path in paths) TryDelete(path); } - /// Returns the total number of event files currently on disk. + // Total number of event files currently on disk. internal int Count() => Directory.GetFiles(_queueDir, "*.json").Length; private static void TryDelete(string path) diff --git a/src/Packages/Audience/Runtime/Transport/EventQueue.cs b/src/Packages/Audience/Runtime/Transport/EventQueue.cs index 0b7491a5f..8f4ae023b 100644 --- a/src/Packages/Audience/Runtime/Transport/EventQueue.cs +++ b/src/Packages/Audience/Runtime/Transport/EventQueue.cs @@ -4,17 +4,11 @@ namespace Immutable.Audience { - /// - /// Thread-safe, disk-persistent batch event queue for the Audience SDK. - /// - /// Enqueue is lock-free and safe to call from any thread. A background - /// drain thread moves events from the in-memory - /// to , flushing either on a time interval or when the - /// in-memory batch reaches . - /// - /// Call before process exit to flush remaining events - /// and stop the drain thread cleanly. - /// + // Thread-safe, disk-persistent batch event queue. + // Enqueue is lock-free and safe from any thread. A background drain + // thread moves events from the in-memory ConcurrentQueue to DiskStore, + // flushing on a time interval or when the batch reaches FlushSize. + // Call Shutdown before process exit. internal sealed class EventQueue : IDisposable { private readonly DiskStore _store; @@ -29,9 +23,9 @@ internal sealed class EventQueue : IDisposable // Volatile so all threads see the shutdown signal immediately. private volatile bool _disposed; - /// Pre-created for this queue. - /// How often to drain to disk regardless of batch size. - /// Drain to disk immediately when this many events are queued. + // store: destination for drained events. + // flushIntervalSeconds: how often to drain to disk regardless of batch size. + // flushSize: drain to disk immediately when this many events are queued. internal EventQueue(DiskStore store, int flushIntervalSeconds, int flushSize) { _store = store ?? throw new ArgumentNullException(nameof(store)); @@ -46,7 +40,7 @@ internal EventQueue(DiskStore store, int flushIntervalSeconds, int flushSize) _drainThread.Start(); } - /// Enqueues a JSON-serialised event. Lock-free; safe from any thread. + // Enqueues a JSON-serialised event. Lock-free; safe from any thread. internal void Enqueue(string json) { if (_disposed) return; @@ -58,19 +52,15 @@ internal void Enqueue(string json) _flushGate.Set(); } - /// - /// Drains the in-memory queue and persists all events to disk immediately. - /// Blocks until the drain is complete. - /// + // Drains the in-memory queue and persists all events to disk + // immediately. Blocks until the drain is complete. internal void FlushSync() { DrainMemoryToDisk(); } - /// - /// Flushes all pending events to disk and stops the drain thread. - /// Safe to call multiple times. - /// + // Flushes all pending events to disk and stops the drain thread. + // Safe to call multiple times. internal void Shutdown() { if (_disposed) return; diff --git a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs index 999b245ca..7590e4229 100644 --- a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs +++ b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs @@ -12,9 +12,7 @@ namespace Immutable.Audience { - /// - /// Sends queued events from to the Audience backend. - /// + // Sends queued events from DiskStore to the Audience backend. internal sealed class HttpTransport : IDisposable { private readonly DiskStore _store; @@ -27,11 +25,10 @@ internal sealed class HttpTransport : IDisposable private int _consecutiveFailures; private DateTime? _nextAttemptAt; - /// Source of event batches. - /// Studio API key. Sent as x-immutable-publishable-key on every request. - /// Optional failure callback. Exceptions thrown inside it are caught and ignored. - /// Optional . Callers can supply a custom pipeline (e.g. specific for test purposes). Defaults to the standard handler when null. - /// Optional UTC clock source used for backoff timing (e.g. swappable for deterministic time). Defaults to DateTime.UtcNow when null. + // store: source of event batches. + // publishableKey: sent as x-immutable-publishable-key on every request. + // onError: optional failure callback. Exceptions thrown inside it are caught. + // handler / getUtcNow: test seams; null for production use. internal HttpTransport( DiskStore store, string publishableKey, @@ -48,10 +45,8 @@ internal HttpTransport( _getUtcNow = getUtcNow ?? (() => DateTime.UtcNow); } - /// - /// Attempts to process one batch: reads it from disk, gzips it, and POSTs it. - /// Returns true if a batch was consumed (outcome irrelevant), false if the queue was empty. - /// + // Processes one batch. Returns true if a batch was consumed + // (outcome irrelevant), false if the queue was empty. internal async Task SendBatchAsync(CancellationToken ct = default) { var batch = _store.ReadBatch(Constants.DefaultFlushSize); @@ -146,16 +141,11 @@ internal async Task SendBatchAsync(CancellationToken ct = default) _ => 60_000, }; - /// - /// Earliest UTC time at which the next attempt may run. - /// Null when no backoff is active (never failed, or last attempt succeeded). - /// + // Earliest UTC time at which the next attempt may run. + // Null when no backoff is active. internal DateTime? NextAttemptAt => _nextAttemptAt; - /// - /// True while UtcNow < NextAttemptAt. Flips false as the clock - /// advances; no reset required. - /// + // True while UtcNow < NextAttemptAt. Flips false as the clock advances. internal bool IsInBackoffWindow => _getUtcNow() < _nextAttemptAt; public void Dispose() @@ -178,14 +168,9 @@ private void ResetBackoff() _nextAttemptAt = null; } - /// - /// Reads each path and wraps the concatenated JSON bodies in - /// {"batch":[msg1,msg2,...]}. - /// - /// - /// The batched JSON, or null if every path was unreadable. Caller - /// treats null as "nothing to send" and deletes the path list. - /// + // Reads each path and wraps the concatenated JSON bodies in + // {"batch":[msg1,msg2,...]}. Returns null if every path was + // unreadable; the caller treats null as "nothing to send". private static string? BuildPayload(IReadOnlyList paths) { var sb = new StringBuilder("{\"batch\":["); diff --git a/src/Packages/Audience/Runtime/Utility/Gzip.cs b/src/Packages/Audience/Runtime/Utility/Gzip.cs index c4d2e88e4..8f8b11c4b 100644 --- a/src/Packages/Audience/Runtime/Utility/Gzip.cs +++ b/src/Packages/Audience/Runtime/Utility/Gzip.cs @@ -5,10 +5,8 @@ namespace Immutable.Audience { - /// - /// Gzip compression using from System.IO.Compression. - /// Available in Unity 2021+ (.NET Standard 2.1). Pure C#, works on all desktop platforms. - /// + // Gzip compression via GZipStream from System.IO.Compression. + // Available in Unity 2021+ (.NET Standard 2.1). Works on all desktop platforms. internal static class Gzip { internal static byte[] Compress(string text) diff --git a/src/Packages/Audience/Tests/Runtime/HttpTransportTests.cs b/src/Packages/Audience/Tests/Runtime/HttpTransportTests.cs index 2d1ff4d2a..30a29f165 100644 --- a/src/Packages/Audience/Tests/Runtime/HttpTransportTests.cs +++ b/src/Packages/Audience/Tests/Runtime/HttpTransportTests.cs @@ -388,10 +388,8 @@ private static string DecompressGzip(byte[] data) } #endif - /// - /// Minimal HttpMessageHandler that returns a canned response. - /// Optionally captures the request for inspection. - /// + // Minimal HttpMessageHandler that returns a canned response. + // Optionally captures the request for inspection. private class MockHandler : HttpMessageHandler { private readonly Func _factory; From 94e2f5668f15e895c6e1541143f99ef812290021 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 22 Apr 2026 04:08:02 +1000 Subject: [PATCH 3/5] refactor(audience): centralise storage paths via AudiencePaths Adds Core/AudiencePaths.cs as the single source of truth for the imtbl_audience/{identity,consent,queue} on-disk layout. Replaces Identity's hardcoded Path.Combine calls with AudiencePaths.IdentityFile and AudiencePaths.AudienceDir. Prepares the namespace for consent and queue path consumers that land in subsequent commits. --- .../Audience/Runtime/Core/AudiencePaths.cs | 24 +++++++++++++++++++ .../Audience/Runtime/Core/Identity.cs | 12 +++------- 2 files changed, 27 insertions(+), 9 deletions(-) create mode 100644 src/Packages/Audience/Runtime/Core/AudiencePaths.cs diff --git a/src/Packages/Audience/Runtime/Core/AudiencePaths.cs b/src/Packages/Audience/Runtime/Core/AudiencePaths.cs new file mode 100644 index 000000000..52fb40c33 --- /dev/null +++ b/src/Packages/Audience/Runtime/Core/AudiencePaths.cs @@ -0,0 +1,24 @@ +using System.IO; + +namespace Immutable.Audience +{ + internal static class AudiencePaths + { + private const string RootDirName = "imtbl_audience"; + private const string IdentityFileName = "identity"; + private const string ConsentFileName = "consent"; + private const string QueueDirName = "queue"; + + internal static string AudienceDir(string persistentDataPath) => + Path.Combine(persistentDataPath, RootDirName); + + internal static string IdentityFile(string persistentDataPath) => + Path.Combine(AudienceDir(persistentDataPath), IdentityFileName); + + internal static string ConsentFile(string persistentDataPath) => + Path.Combine(AudienceDir(persistentDataPath), ConsentFileName); + + internal static string QueueDir(string persistentDataPath) => + Path.Combine(AudienceDir(persistentDataPath), QueueDirName); + } +} diff --git a/src/Packages/Audience/Runtime/Core/Identity.cs b/src/Packages/Audience/Runtime/Core/Identity.cs index 70033f4c7..2b1143d38 100644 --- a/src/Packages/Audience/Runtime/Core/Identity.cs +++ b/src/Packages/Audience/Runtime/Core/Identity.cs @@ -15,12 +15,6 @@ internal sealed class Identity private static volatile string _cachedId; private static readonly object _sync = new object(); - private static string GetDirectory(string persistentDataPath) => - Path.Combine(persistentDataPath, "imtbl_audience"); - - private static string GetFilePath(string persistentDataPath) => - Path.Combine(GetDirectory(persistentDataPath), "identity"); - // Returns the anonymous ID, generating and persisting it on first call. // Returns null without touching disk when consent is None. // Safe to call from any thread after ImmutableAudience.Init() has run on the main thread. @@ -41,10 +35,10 @@ internal static string GetOrCreate(string persistentDataPath, ConsentLevel conse if (_cachedId != null) return _cachedId; - var dir = GetDirectory(persistentDataPath); + var dir = AudiencePaths.AudienceDir(persistentDataPath); Directory.CreateDirectory(dir); // no-op if already exists - var filePath = GetFilePath(persistentDataPath); + var filePath = AudiencePaths.IdentityFile(persistentDataPath); // Returning player โ€” read the ID we wrote on a previous launch. if (File.Exists(filePath)) @@ -85,7 +79,7 @@ internal static void Reset(string persistentDataPath) { _cachedId = null; - var filePath = GetFilePath(persistentDataPath); + var filePath = AudiencePaths.IdentityFile(persistentDataPath); try { File.Delete(filePath); From 8b84c694f49212bf44cec3540a97d50e97d1c6a2 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 22 Apr 2026 04:08:10 +1000 Subject: [PATCH 4/5] feat(audience): add IL2CPP-safe JsonReader utility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complements the existing Utility/Json writer with a reader half. Reflection-free โ€” parses the subset of JSON that Json.cs emits (objects, strings, numbers, booleans, null, arrays). Throws FormatException on malformed input. Used wherever the SDK needs to deserialise its own previously-written payloads. --- .../Audience/Runtime/Utility/JsonReader.cs | 181 ++++++++++++++++++ .../Tests/Runtime/Utility/JsonReaderTests.cs | 103 ++++++++++ 2 files changed, 284 insertions(+) create mode 100644 src/Packages/Audience/Runtime/Utility/JsonReader.cs create mode 100644 src/Packages/Audience/Tests/Runtime/Utility/JsonReaderTests.cs diff --git a/src/Packages/Audience/Runtime/Utility/JsonReader.cs b/src/Packages/Audience/Runtime/Utility/JsonReader.cs new file mode 100644 index 000000000..bd516dc63 --- /dev/null +++ b/src/Packages/Audience/Runtime/Utility/JsonReader.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace Immutable.Audience +{ + // Minimal JSON reader. Handles the subset produced by Json.Serialize: + // objects, strings, numbers, booleans, null, arrays. Reflection-free so + // IL2CPP-safe. Throws FormatException on malformed input. + internal static class JsonReader + { + internal static Dictionary DeserializeObject(string json) + { + var p = new Parser(json); + p.SkipWhitespace(); + var result = p.ReadObject(); + p.SkipWhitespace(); + if (p.Pos != json.Length) + throw new FormatException($"Trailing content at position {p.Pos}"); + return result; + } + + private struct Parser + { + private readonly string _s; + internal int Pos; + + internal Parser(string s) { _s = s; Pos = 0; } + + internal void SkipWhitespace() + { + while (Pos < _s.Length) + { + var c = _s[Pos]; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') Pos++; + else break; + } + } + + internal Dictionary ReadObject() + { + Expect('{'); + var obj = new Dictionary(); + SkipWhitespace(); + if (Peek() == '}') { Pos++; return obj; } + + while (true) + { + SkipWhitespace(); + var key = ReadString(); + SkipWhitespace(); + Expect(':'); + SkipWhitespace(); + obj[key] = ReadValue(); + SkipWhitespace(); + var next = Read(); + if (next == ',') continue; + if (next == '}') return obj; + throw new FormatException($"Expected ',' or '}}' at position {Pos - 1}"); + } + } + + private List ReadArray() + { + Expect('['); + var arr = new List(); + SkipWhitespace(); + if (Peek() == ']') { Pos++; return arr; } + + while (true) + { + SkipWhitespace(); + arr.Add(ReadValue()); + SkipWhitespace(); + var next = Read(); + if (next == ',') continue; + if (next == ']') return arr; + throw new FormatException($"Expected ',' or ']' at position {Pos - 1}"); + } + } + + private object ReadValue() + { + SkipWhitespace(); + var c = Peek(); + if (c == '"') return ReadString(); + if (c == '{') return ReadObject(); + if (c == '[') return ReadArray(); + if (c == 't' || c == 'f') return ReadBool(); + if (c == 'n') { ReadLiteral("null"); return null; } + return ReadNumber(); + } + + private string ReadString() + { + Expect('"'); + var sb = new StringBuilder(); + while (Pos < _s.Length) + { + var c = _s[Pos++]; + if (c == '"') return sb.ToString(); + if (c == '\\') + { + if (Pos >= _s.Length) throw new FormatException("Unterminated escape"); + var esc = _s[Pos++]; + switch (esc) + { + case '"': sb.Append('"'); break; + case '\\': sb.Append('\\'); break; + case '/': sb.Append('/'); break; + case 'b': sb.Append('\b'); break; + case 'f': sb.Append('\f'); break; + case 'n': sb.Append('\n'); break; + case 'r': sb.Append('\r'); break; + case 't': sb.Append('\t'); break; + case 'u': + if (Pos + 4 > _s.Length) throw new FormatException("Truncated \\u escape"); + var hex = _s.Substring(Pos, 4); + Pos += 4; + sb.Append((char)int.Parse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture)); + break; + default: throw new FormatException($"Invalid escape \\{esc}"); + } + } + else sb.Append(c); + } + throw new FormatException("Unterminated string"); + } + + private object ReadNumber() + { + var start = Pos; + if (Peek() == '-') Pos++; + while (Pos < _s.Length) + { + var c = _s[Pos]; + if ((c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-') Pos++; + else break; + } + var token = _s.Substring(start, Pos - start); + if (token.IndexOfAny(new[] { '.', 'e', 'E' }) < 0 + && long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l)) + { + if (l >= int.MinValue && l <= int.MaxValue) return (int)l; + return l; + } + if (double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)) + return d; + throw new FormatException($"Invalid number '{token}'"); + } + + private bool ReadBool() + { + if (Peek() == 't') { ReadLiteral("true"); return true; } + ReadLiteral("false"); + return false; + } + + private void ReadLiteral(string literal) + { + if (Pos + literal.Length > _s.Length || _s.Substring(Pos, literal.Length) != literal) + throw new FormatException($"Expected '{literal}' at position {Pos}"); + Pos += literal.Length; + } + + private char Peek() => + Pos < _s.Length ? _s[Pos] : throw new FormatException("Unexpected end of input"); + + private char Read() => + Pos < _s.Length ? _s[Pos++] : throw new FormatException("Unexpected end of input"); + + private void Expect(char c) + { + if (Pos >= _s.Length || _s[Pos] != c) + throw new FormatException($"Expected '{c}' at position {Pos}"); + Pos++; + } + } + } +} diff --git a/src/Packages/Audience/Tests/Runtime/Utility/JsonReaderTests.cs b/src/Packages/Audience/Tests/Runtime/Utility/JsonReaderTests.cs new file mode 100644 index 000000000..839feb0b6 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/Utility/JsonReaderTests.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + public class JsonReaderTests + { + [Test] + public void EmptyObject() + { + var result = JsonReader.DeserializeObject("{}"); + Assert.AreEqual(0, result.Count); + } + + [Test] + public void StringValue() + { + var result = JsonReader.DeserializeObject("{\"key\":\"hello\"}"); + Assert.AreEqual("hello", result["key"]); + } + + [Test] + public void StringWithEscapes() + { + var result = JsonReader.DeserializeObject("{\"val\":\"say \\\"hi\\\"\\nback\\\\slash\\ttab\"}"); + Assert.AreEqual("say \"hi\"\nback\\slash\ttab", result["val"]); + } + + [Test] + public void IntAndLong() + { + var result = JsonReader.DeserializeObject("{\"small\":42,\"big\":12345678901234}"); + Assert.AreEqual(42, result["small"]); + Assert.AreEqual(12345678901234L, result["big"]); + } + + [Test] + public void BoolAndNull() + { + var result = JsonReader.DeserializeObject("{\"t\":true,\"f\":false,\"n\":null}"); + Assert.AreEqual(true, result["t"]); + Assert.AreEqual(false, result["f"]); + Assert.IsNull(result["n"]); + } + + [Test] + public void NestedObject() + { + var result = JsonReader.DeserializeObject("{\"outer\":{\"inner\":\"value\"}}"); + var inner = (Dictionary)result["outer"]; + Assert.AreEqual("value", inner["inner"]); + } + + [Test] + public void Array() + { + var result = JsonReader.DeserializeObject("{\"arr\":[1,\"two\",true,null]}"); + var arr = (List)result["arr"]; + Assert.AreEqual(4, arr.Count); + Assert.AreEqual(1, arr[0]); + Assert.AreEqual("two", arr[1]); + Assert.AreEqual(true, arr[2]); + Assert.IsNull(arr[3]); + } + + [Test] + public void RoundTripViaSerializer() + { + var original = new Dictionary + { + ["type"] = "track", + ["eventName"] = "progression", + ["properties"] = new Dictionary + { + ["status"] = "complete", + ["score"] = 1500 + }, + ["anonymousId"] = "abc", + ["userId"] = "76561198012345" + }; + + var serialized = Json.Serialize(original); + var parsed = JsonReader.DeserializeObject(serialized); + + Assert.AreEqual("track", parsed["type"]); + Assert.AreEqual("progression", parsed["eventName"]); + Assert.AreEqual("abc", parsed["anonymousId"]); + Assert.AreEqual("76561198012345", parsed["userId"]); + var props = (Dictionary)parsed["properties"]; + Assert.AreEqual("complete", props["status"]); + Assert.AreEqual(1500, props["score"]); + } + + [Test] + public void MalformedThrows() + { + Assert.Throws(() => JsonReader.DeserializeObject("{not valid}")); + Assert.Throws(() => JsonReader.DeserializeObject("{\"a\":}")); + Assert.Throws(() => JsonReader.DeserializeObject("{\"a\":\"unterminated")); + } + } +} From bef433ab0e9adcbaa000c841b280a5e1e2b96f07 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 22 Apr 2026 04:08:19 +1000 Subject: [PATCH 5/5] feat(audience): add Log utility with injectable writer Debug/Warn logger used by SDK internals for diagnostics. Writer is injectable so tests can capture output and AudienceUnityHooks can install Debug.Log as the sink under Unity. Debug calls are gated by an Enabled toggle (AudienceConfig.Debug); Warn always emits. Falls back to Console.WriteLine when no Writer is installed so headless .NET consumers still see output. --- src/Packages/Audience/Runtime/Utility/Log.cs | 33 ++++++++++ .../Tests/Runtime/Utility/LogTests.cs | 60 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/Packages/Audience/Runtime/Utility/Log.cs create mode 100644 src/Packages/Audience/Tests/Runtime/Utility/LogTests.cs diff --git a/src/Packages/Audience/Runtime/Utility/Log.cs b/src/Packages/Audience/Runtime/Utility/Log.cs new file mode 100644 index 000000000..af7ee940d --- /dev/null +++ b/src/Packages/Audience/Runtime/Utility/Log.cs @@ -0,0 +1,33 @@ +using System; + +namespace Immutable.Audience +{ + internal static class Log + { + private const string Prefix = "[ImmutableAudience]"; + + internal static bool Enabled { get; set; } + + // Tests set this to capture output; AudienceUnityHooks sets it to Debug.Log. + internal static Action Writer { get; set; } + + internal static void Debug(string message) + { + if (!Enabled) return; + Emit($"{Prefix} {message}"); + } + + internal static void Warn(string message) => + Emit($"{Prefix} WARN: {message}"); + + private static void Emit(string line) + { + if (Writer != null) + { + Writer(line); + return; + } + Console.WriteLine(line); + } + } +} diff --git a/src/Packages/Audience/Tests/Runtime/Utility/LogTests.cs b/src/Packages/Audience/Tests/Runtime/Utility/LogTests.cs new file mode 100644 index 000000000..42a3cda0c --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/Utility/LogTests.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class LogTests + { + private List _captured; + + [SetUp] + public void SetUp() + { + _captured = new List(); + Log.Writer = line => _captured.Add(line); + Log.Enabled = false; + } + + [TearDown] + public void TearDown() + { + Log.Writer = null; + Log.Enabled = false; + } + + [Test] + public void Debug_WhenDisabled_EmitsNothing() + { + Log.Enabled = false; + + Log.Debug("silent"); + + Assert.AreEqual(0, _captured.Count); + } + + [Test] + public void Debug_WhenEnabled_EmitsWithPrefix() + { + Log.Enabled = true; + + Log.Debug("hello"); + + Assert.AreEqual(1, _captured.Count); + StringAssert.StartsWith("[ImmutableAudience]", _captured[0]); + StringAssert.Contains("hello", _captured[0]); + } + + [Test] + public void Warn_AlwaysEmits_EvenWhenDisabled() + { + Log.Enabled = false; + + Log.Warn("something off"); + + Assert.AreEqual(1, _captured.Count); + StringAssert.Contains("WARN", _captured[0]); + StringAssert.Contains("something off", _captured[0]); + } + } +}