From d794f758af631da1b9e3fb156a1f81a4b0d68f06 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 22 Apr 2026 04:08:32 +1000 Subject: [PATCH 1/7] feat(audience): add typed event schemas and IdentityType (SDK-147) Public schema surface for the Audience SDK: - IEvent interface: EventName + ToProperties contract that the singleton's Track(IEvent) overload consumes. Usable by custom IEvent implementations as well as the built-in typed events. - Progression / Resource / Purchase / MilestoneReached: the four typed gameplay events with Required / Optional field hints and enum-to-wire mappers that throw on unknown casts. - Purchase validates ISO 4217 three-letter uppercase currency codes via a hand-rolled IsIso4217 helper (no System.Text.RegularExpressions, keeps IL2CPP build small). - MilestoneReached rejects null or empty Name. - ProgressionStatus (Start/Complete/Fail) and ResourceFlow (Source/Sink) enums with matching ToWireString extensions. - IdentityType enum for the eight backend-accepted provider names (Passport, Steam, Epic, Google, Apple, Discord, Email, Custom) plus its ToWireString mapper. The event classes throw ArgumentException from ToProperties on invalid payloads; the singleton's Track(IEvent) catches and drops with a warning so a buggy call site cannot crash the game. --- .../Audience/Runtime/Events/IEvent.cs | 11 ++ .../Audience/Runtime/Events/TypedEvents.cs | 175 ++++++++++++++++++ src/Packages/Audience/Runtime/IdentityType.cs | 34 ++++ .../Tests/Runtime/Events/TypedEventTests.cs | 132 +++++++++++++ .../Tests/Runtime/IdentityTypeTests.cs | 32 ++++ 5 files changed, 384 insertions(+) create mode 100644 src/Packages/Audience/Runtime/Events/IEvent.cs create mode 100644 src/Packages/Audience/Runtime/Events/TypedEvents.cs create mode 100644 src/Packages/Audience/Runtime/IdentityType.cs create mode 100644 src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs create mode 100644 src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs diff --git a/src/Packages/Audience/Runtime/Events/IEvent.cs b/src/Packages/Audience/Runtime/Events/IEvent.cs new file mode 100644 index 000000000..15961c2cb --- /dev/null +++ b/src/Packages/Audience/Runtime/Events/IEvent.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Immutable.Audience +{ + // Typed event contract for ImmutableAudience.Track(IEvent). + public interface IEvent + { + string EventName { get; } + Dictionary ToProperties(); + } +} \ No newline at end of file diff --git a/src/Packages/Audience/Runtime/Events/TypedEvents.cs b/src/Packages/Audience/Runtime/Events/TypedEvents.cs new file mode 100644 index 000000000..82d779264 --- /dev/null +++ b/src/Packages/Audience/Runtime/Events/TypedEvents.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; + +namespace Immutable.Audience +{ + // Progression event state. + public enum ProgressionStatus + { + Start, + Complete, + Fail + } + + internal static class ProgressionStatusExtensions + { + // Throws on unknown casts. Progression.ToProperties propagates, and + // Track(IEvent) catches + drops with a warning. + internal static string ToLowercaseString(this ProgressionStatus status) => status switch + { + ProgressionStatus.Start => "start", + ProgressionStatus.Complete => "complete", + ProgressionStatus.Fail => "fail", + _ => throw new ArgumentOutOfRangeException( + nameof(status), status, "Unhandled ProgressionStatus"), + }; + } + + // Player progressing through a world / level / stage. + public class Progression : IEvent + { + // Required. + public ProgressionStatus Status { get; set; } + // Optional. + public string World { get; set; } + public string Level { get; set; } + public string Stage { get; set; } + public int? Score { get; set; } + public float? DurationSec { get; set; } + + public string EventName => "progression"; + + public Dictionary ToProperties() + { + var props = new Dictionary + { + ["status"] = Status.ToLowercaseString() + }; + + if (World != null) props["world"] = World; + if (Level != null) props["level"] = Level; + if (Stage != null) props["stage"] = Stage; + if (Score.HasValue) props["score"] = Score.Value; + if (DurationSec.HasValue) props["durationSec"] = DurationSec.Value; + + return props; + } + } + + // Resource flow direction. + public enum ResourceFlow + { + Source, + Sink + } + + internal static class ResourceFlowExtensions + { + // Throws on unknown casts. Resource.ToProperties propagates, and + // Track(IEvent) catches + drops with a warning. + internal static string ToLowercaseString(this ResourceFlow flow) => flow switch + { + ResourceFlow.Source => "source", + ResourceFlow.Sink => "sink", + _ => throw new ArgumentOutOfRangeException( + nameof(flow), flow, "Unhandled ResourceFlow"), + }; + } + + // In-game currency earned or spent. + public class Resource : IEvent + { + // Required. + public ResourceFlow Flow { get; set; } + public string Currency { get; set; } + public float Amount { get; set; } + // Optional. + public string ItemType { get; set; } + public string ItemId { get; set; } + + public string EventName => "resource"; + + public Dictionary ToProperties() + { + var props = new Dictionary + { + ["flow"] = Flow.ToLowercaseString(), + ["currency"] = Currency, + ["amount"] = Amount + }; + + if (ItemType != null) props["itemType"] = ItemType; + if (ItemId != null) props["itemId"] = ItemId; + + return props; + } + } + + // Real-money transaction. + public class Purchase : IEvent + { + // Required. ISO 4217 three-letter uppercase currency code. + public string Currency { get; set; } + // Required. + public decimal Value { get; set; } + // Optional. + public string ItemId { get; set; } + public string ItemName { get; set; } + public int? Quantity { get; set; } + public string TransactionId { get; set; } + + public string EventName => "purchase"; + + // Hand-rolled to avoid pulling System.Text.RegularExpressions into the IL2CPP build. + private static bool IsIso4217(string s) + { + if (s == null || s.Length != 3) return false; + for (var i = 0; i < 3; i++) + { + var c = s[i]; + if (c < 'A' || c > 'Z') return false; + } + return true; + } + + public Dictionary ToProperties() + { + if (!IsIso4217(Currency)) + throw new ArgumentException( + $"Purchase.Currency '{Currency}' must be a three-letter uppercase ISO 4217 code"); + + var props = new Dictionary + { + ["currency"] = Currency, + ["value"] = Value + }; + + if (ItemId != null) props["itemId"] = ItemId; + if (ItemName != null) props["itemName"] = ItemName; + if (Quantity.HasValue) props["quantity"] = Quantity.Value; + if (TransactionId != null) props["transactionId"] = TransactionId; + + return props; + } + } + + // Named milestone or achievement. + public class MilestoneReached : IEvent + { + // Required. + public string Name { get; set; } + + public string EventName => "milestone_reached"; + + public Dictionary ToProperties() + { + if (string.IsNullOrEmpty(Name)) + throw new ArgumentException("MilestoneReached.Name must not be null or empty"); + + return new Dictionary + { + ["name"] = Name + }; + } + } +} diff --git a/src/Packages/Audience/Runtime/IdentityType.cs b/src/Packages/Audience/Runtime/IdentityType.cs new file mode 100644 index 000000000..172eefc7b --- /dev/null +++ b/src/Packages/Audience/Runtime/IdentityType.cs @@ -0,0 +1,34 @@ +namespace Immutable.Audience +{ + // Identity provider accepted by the Audience backend. + public enum IdentityType + { + Passport, + Steam, + Epic, + Google, + Apple, + Discord, + Email, + Custom, + } + + internal static class IdentityTypeExtensions + { + // Returns null on unknown casts. The string overloads of Identify / + // Alias check for null/empty and drop + warn, so an out-of-range + // cast surfaces as a dropped event, not a corrupt wire payload. + internal static string ToLowercaseString(this IdentityType type) => type switch + { + IdentityType.Passport => "passport", + IdentityType.Steam => "steam", + IdentityType.Epic => "epic", + IdentityType.Google => "google", + IdentityType.Apple => "apple", + IdentityType.Discord => "discord", + IdentityType.Email => "email", + IdentityType.Custom => "custom", + _ => null, + }; + } +} diff --git a/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs b/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs new file mode 100644 index 000000000..67c4ad861 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs @@ -0,0 +1,132 @@ +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class TypedEventTests + { + [Test] + public void Progression_EventName_IsProgression() + { + Assert.AreEqual("progression", new Progression().EventName); + } + + [Test] + public void Progression_Complete_ProducesCorrectProperties() + { + var evt = new Progression + { + Status = ProgressionStatus.Complete, + World = "tutorial", + Level = "1", + Score = 1500, + DurationSec = 120.5f + }; + + var props = evt.ToProperties(); + + Assert.AreEqual("complete", props["status"]); + Assert.AreEqual("tutorial", props["world"]); + Assert.AreEqual("1", props["level"]); + Assert.AreEqual(1500, props["score"]); + Assert.AreEqual(120.5f, props["durationSec"]); + } + + [Test] + public void Progression_OptionalFieldsOmitted_WhenNull() + { + var props = new Progression { Status = ProgressionStatus.Start }.ToProperties(); + + Assert.IsTrue(props.ContainsKey("status")); + Assert.IsFalse(props.ContainsKey("world")); + Assert.IsFalse(props.ContainsKey("level")); + Assert.IsFalse(props.ContainsKey("stage")); + Assert.IsFalse(props.ContainsKey("score")); + Assert.IsFalse(props.ContainsKey("durationSec")); + } + + [Test] + public void Resource_Source_ProducesCorrectProperties() + { + var evt = new Resource + { + Flow = ResourceFlow.Source, + Currency = "gold", + Amount = 100, + ItemType = "quest_reward", + ItemId = "main_quest_01" + }; + + var props = evt.ToProperties(); + + Assert.AreEqual("source", props["flow"]); + Assert.AreEqual("gold", props["currency"]); + Assert.AreEqual(100m, props["amount"]); + Assert.AreEqual("quest_reward", props["itemType"]); + Assert.AreEqual("main_quest_01", props["itemId"]); + } + + [Test] + public void Resource_EventName_IsResource() + { + Assert.AreEqual("resource", new Resource().EventName); + } + + [Test] + public void Purchase_ProducesCorrectProperties() + { + var evt = new Purchase + { + Currency = "USD", + Value = 9.99m, + ItemId = "gem_pack_01", + ItemName = "Starter Gem Pack", + Quantity = 1, + TransactionId = "txn_abc123" + }; + + var props = evt.ToProperties(); + + Assert.AreEqual("USD", props["currency"]); + Assert.AreEqual(9.99m, props["value"]); + Assert.AreEqual("gem_pack_01", props["itemId"]); + Assert.AreEqual("Starter Gem Pack", props["itemName"]); + Assert.AreEqual(1, props["quantity"]); + Assert.AreEqual("txn_abc123", props["transactionId"]); + } + + [Test] + public void Purchase_OptionalFieldsOmitted_WhenNull() + { + var props = new Purchase { Currency = "EUR", Value = 5.00m }.ToProperties(); + + Assert.IsTrue(props.ContainsKey("currency")); + Assert.IsTrue(props.ContainsKey("value")); + Assert.IsFalse(props.ContainsKey("itemId")); + Assert.IsFalse(props.ContainsKey("itemName")); + Assert.IsFalse(props.ContainsKey("quantity")); + Assert.IsFalse(props.ContainsKey("transactionId")); + } + + [Test] + public void Purchase_EventName_IsPurchase() + { + Assert.AreEqual("purchase", new Purchase().EventName); + } + + [Test] + public void MilestoneReached_ProducesCorrectProperties() + { + var props = new MilestoneReached { Name = "first_boss_defeated" }.ToProperties(); + + Assert.AreEqual("first_boss_defeated", props["name"]); + Assert.AreEqual(1, props.Count); + } + + [Test] + public void MilestoneReached_EventName_IsMilestoneReached() + { + Assert.AreEqual("milestone_reached", new MilestoneReached().EventName); + } + } +} \ No newline at end of file diff --git a/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs b/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs new file mode 100644 index 000000000..7b2ed71c7 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs @@ -0,0 +1,32 @@ +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class IdentityTypeTests + { + [TestCase(IdentityType.Passport, "passport")] + [TestCase(IdentityType.Steam, "steam")] + [TestCase(IdentityType.Epic, "epic")] + [TestCase(IdentityType.Google, "google")] + [TestCase(IdentityType.Apple, "apple")] + [TestCase(IdentityType.Discord, "discord")] + [TestCase(IdentityType.Email, "email")] + [TestCase(IdentityType.Custom, "custom")] + public void ToLowercaseString_MapsEachEnumValueToLowercaseBackendString(IdentityType type, string expected) + { + Assert.AreEqual(expected, type.ToLowercaseString()); + } + + [Test] + public void ToLowercaseString_UnknownValue_ReturnsNull() + { + // Never-throw contract: an out-of-range cast should not surface an + // exception on the game thread. Callers drop the event via their + // null/empty check instead. + var invalid = (IdentityType)999; + + Assert.IsNull(invalid.ToLowercaseString()); + } + } +} From 86218c78e1901a4a2b4d3fc235cb25fea31eac5d Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 22 Apr 2026 04:18:59 +1000 Subject: [PATCH 2/7] refactor(audience): extend Identity with Get + ClearCache, relocate tests to Core/ Adds two methods to Identity that support the SDK's GDPR and lifecycle needs: Get returns the existing anonymousId without minting a new one (so GDPR DeleteData doesn't register an id just to delete it), and ClearCache drops the in-memory cache without touching disk (so re-Init with a different persistentDataPath reads the new file instead of returning the previous session's id). Relocates the test fixture from Tests/Runtime/IdentityTests.cs to Tests/Runtime/Core/IdentityTests.cs to mirror Runtime/Core/Identity.cs. The relocation expands coverage with two new tests for Get (Get_NoExistingFile_ReturnsNull_AndDoesNotCreate, Get_ExistingFile_ReturnsPersistedId) and swaps hardcoded Path.Combine(_testDir, "imtbl_audience", "identity") calls for the AudiencePaths.IdentityFile helper. The original five tests (NewDirectory_*, ExistingFile_*, SecondCall_*, Reset_*, ConsentNone_*) are preserved byte-equivalent modulo the path helper swap. --- .../Audience/Runtime/Core/Identity.cs | 40 +++++++++++++++++++ .../Tests/Runtime/{ => Core}/IdentityTests.cs | 29 ++++++++++++-- 2 files changed, 65 insertions(+), 4 deletions(-) rename src/Packages/Audience/Tests/Runtime/{ => Core}/IdentityTests.cs (70%) diff --git a/src/Packages/Audience/Runtime/Core/Identity.cs b/src/Packages/Audience/Runtime/Core/Identity.cs index 2b1143d38..aa8da17dc 100644 --- a/src/Packages/Audience/Runtime/Core/Identity.cs +++ b/src/Packages/Audience/Runtime/Core/Identity.cs @@ -15,6 +15,46 @@ internal sealed class Identity private static volatile string _cachedId; private static readonly object _sync = new object(); + // Returns the existing anonymous ID, or null if none exists. + // Unlike GetOrCreate, never generates or persists a new one. + internal static string Get(string persistentDataPath) + { + if (_cachedId != null) return _cachedId; + + lock (_sync) + { + if (_cachedId != null) return _cachedId; + + try + { + var filePath = AudiencePaths.IdentityFile(persistentDataPath); + if (!File.Exists(filePath)) return null; + + _cachedId = File.ReadAllText(filePath).Trim(); + return _cachedId; + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + } + + // Drops the in-memory cache without touching disk. Called on + // Shutdown/ResetState so a subsequent Init with a different + // persistentDataPath re-reads the file from the new location. + internal static void ClearCache() + { + lock (_sync) + { + _cachedId = null; + } + } + // 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. diff --git a/src/Packages/Audience/Tests/Runtime/IdentityTests.cs b/src/Packages/Audience/Tests/Runtime/Core/IdentityTests.cs similarity index 70% rename from src/Packages/Audience/Tests/Runtime/IdentityTests.cs rename to src/Packages/Audience/Tests/Runtime/Core/IdentityTests.cs index 1518d62d6..cd2cf5fd6 100644 --- a/src/Packages/Audience/Tests/Runtime/IdentityTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Core/IdentityTests.cs @@ -31,7 +31,7 @@ public void NewDirectory_GeneratesNonEmptyId_AndWritesFile() Assert.IsNotNull(id); Assert.IsNotEmpty(id); - var filePath = Path.Combine(_testDir, "imtbl_audience", "identity"); + var filePath = AudiencePaths.IdentityFile(_testDir); Assert.IsTrue(File.Exists(filePath), "identity file should exist on disk"); } @@ -40,9 +40,9 @@ public void ExistingFile_ReturnsPreviousId_WithoutGeneratingNew() { // Simulate a returning player by pre-writing an identity file (as a previous launch would have done). var expectedId = "pre-existing-id-from-last-launch"; - var dir = Path.Combine(_testDir, "imtbl_audience"); + var dir = AudiencePaths.AudienceDir(_testDir); Directory.CreateDirectory(dir); - File.WriteAllText(Path.Combine(dir, "identity"), expectedId); + File.WriteAllText(AudiencePaths.IdentityFile(_testDir), expectedId); var result = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); @@ -76,8 +76,29 @@ public void ConsentNone_ReturnsNull_AndNoFileWritten() Assert.IsNull(id); - var filePath = Path.Combine(_testDir, "imtbl_audience", "identity"); + var filePath = AudiencePaths.IdentityFile(_testDir); Assert.IsFalse(File.Exists(filePath), "identity file must not be written when consent is None"); } + + [Test] + public void Get_NoExistingFile_ReturnsNull_AndDoesNotCreate() + { + var id = Identity.Get(_testDir); + + Assert.IsNull(id); + var filePath = AudiencePaths.IdentityFile(_testDir); + Assert.IsFalse(File.Exists(filePath), "Get must not create the identity file"); + } + + [Test] + public void Get_ExistingFile_ReturnsPersistedId() + { + var expectedId = "pre-existing-id"; + var dir = AudiencePaths.AudienceDir(_testDir); + Directory.CreateDirectory(dir); + File.WriteAllText(AudiencePaths.IdentityFile(_testDir), expectedId); + + Assert.AreEqual(expectedId, Identity.Get(_testDir)); + } } } From a254ff551fe4f7e9a325a304ce0e45961d956956 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 22 Apr 2026 04:28:51 +1000 Subject: [PATCH 3/7] refactor(audience): extract named constants for HTTP header, URL helper, and field length Replaces the x-immutable-publishable-key literal, the inline BaseUrl(...) + MessagesPath URL construction, and the repeated 256 MaxFieldLength literal in MessageBuilder with named Constants helpers. Pure refactor of code already on main - no behaviour change. --- .../Audience/Runtime/Core/Constants.cs | 5 +++++ .../Audience/Runtime/Events/MessageBuilder.cs | 22 +++++++++---------- .../Runtime/Transport/HttpTransport.cs | 4 ++-- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index a7ed09792..ad32356ac 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -14,11 +14,16 @@ internal static class Constants internal const int DefaultFlushSize = 20; internal const int MaxBatchSize = 100; internal const int StaleEventDays = 30; + internal const int MaxFieldLength = 256; // Backend schema limit. internal const string LibraryName = "com.immutable.audience"; internal const string Surface = "unity"; internal const string ConsentSource = "UnitySDK"; + internal const string PublishableKeyHeader = "x-immutable-publishable-key"; + + internal static string MessagesUrl(string publishableKey) => BaseUrl(publishableKey) + MessagesPath; + internal static string BaseUrl(string publishableKey) => publishableKey != null && publishableKey.StartsWith(TestKeyPrefix) ? SandboxBaseUrl diff --git a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs index efc3ec396..10b20a2cb 100644 --- a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs +++ b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs @@ -13,13 +13,13 @@ internal static Dictionary Track( Dictionary properties = null) { var msg = BuildBase("track", packageVersion); - msg["eventName"] = Truncate(eventName, 256); + msg["eventName"] = Truncate(eventName, Constants.MaxFieldLength); if (!string.IsNullOrEmpty(anonymousId)) - msg["anonymousId"] = Truncate(anonymousId, 256); + msg["anonymousId"] = Truncate(anonymousId, Constants.MaxFieldLength); if (!string.IsNullOrEmpty(userId)) - msg["userId"] = Truncate(userId, 256); + msg["userId"] = Truncate(userId, Constants.MaxFieldLength); if (properties != null && properties.Count > 0) msg["properties"] = properties; @@ -37,13 +37,13 @@ internal static Dictionary Identify( var msg = BuildBase("identify", packageVersion); if (!string.IsNullOrEmpty(anonymousId)) - msg["anonymousId"] = Truncate(anonymousId, 256); + msg["anonymousId"] = Truncate(anonymousId, Constants.MaxFieldLength); if (!string.IsNullOrEmpty(userId)) - msg["userId"] = Truncate(userId, 256); + msg["userId"] = Truncate(userId, Constants.MaxFieldLength); if (!string.IsNullOrEmpty(identityType)) - msg["identityType"] = Truncate(identityType, 256); + msg["identityType"] = Truncate(identityType, Constants.MaxFieldLength); if (traits != null && traits.Count > 0) msg["traits"] = traits; @@ -59,10 +59,10 @@ internal static Dictionary Alias( string packageVersion) { var msg = BuildBase("alias", packageVersion); - msg["fromId"] = Truncate(fromId, 256); - msg["fromType"] = Truncate(fromType, 256); - msg["toId"] = Truncate(toId, 256); - msg["toType"] = Truncate(toType, 256); + msg["fromId"] = Truncate(fromId, Constants.MaxFieldLength); + msg["fromType"] = Truncate(fromType, Constants.MaxFieldLength); + msg["toId"] = Truncate(toId, Constants.MaxFieldLength); + msg["toType"] = Truncate(toType, Constants.MaxFieldLength); return msg; } @@ -76,7 +76,7 @@ private static Dictionary BuildBase(string type, string packageV ["context"] = new Dictionary { ["library"] = Constants.LibraryName, - ["libraryVersion"] = Truncate(packageVersion, 256) + ["libraryVersion"] = Truncate(packageVersion, Constants.MaxFieldLength) }, ["surface"] = Constants.Surface }; diff --git a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs index 7590e4229..67750ccb7 100644 --- a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs +++ b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs @@ -38,7 +38,7 @@ internal HttpTransport( { _store = store ?? throw new ArgumentNullException(nameof(store)); _publishableKey = publishableKey ?? throw new ArgumentNullException(nameof(publishableKey)); - _url = Constants.BaseUrl(publishableKey) + Constants.MessagesPath; + _url = Constants.MessagesUrl(publishableKey); _onError = onError; _client = handler != null ? new HttpClient(handler) : new HttpClient(); _client.Timeout = TimeSpan.FromSeconds(30); @@ -78,7 +78,7 @@ internal async Task SendBatchAsync(CancellationToken ct = default) try { using var request = new HttpRequestMessage(HttpMethod.Post, _url); - request.Headers.Add("x-immutable-publishable-key", _publishableKey); + request.Headers.Add(Constants.PublishableKeyHeader, _publishableKey); #if IMMUTABLE_AUDIENCE_GZIP var compressed = Gzip.Compress(payload); request.Content = new ByteArrayContent(compressed); From 6a8c99eea2d37a04a546ddf9ba4532ca5c694aaf Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 22 Apr 2026 04:38:56 +1000 Subject: [PATCH 4/7] refactor(audience): mirror Runtime folder layout onto Tests Runtime is already organised into Core/, Events/, Transport/, Utility/. Tests has had matching subfolders since the Identity, typed-event, JsonReader, and Log peels landed, but six fixtures were still sitting flat at the root. Moves them under the matching subfolder so Tests mirrors Runtime one-to-one. Pure rename, no content change. csproj uses glob includes so no build file update is needed. - MessageBuilderTests.cs -> Events/ - DiskStoreTests.cs (+.meta) -> Transport/ - EventQueueTests.cs (+.meta) -> Transport/ - HttpTransportTests.cs -> Transport/ - GzipTests.cs -> Utility/ - JsonTests.cs -> Utility/ --- .../Audience/Tests/Runtime/{ => Events}/MessageBuilderTests.cs | 0 .../Audience/Tests/Runtime/{ => Transport}/DiskStoreTests.cs | 0 .../Audience/Tests/Runtime/{ => Transport}/DiskStoreTests.cs.meta | 0 .../Audience/Tests/Runtime/{ => Transport}/EventQueueTests.cs | 0 .../Tests/Runtime/{ => Transport}/EventQueueTests.cs.meta | 0 .../Audience/Tests/Runtime/{ => Transport}/HttpTransportTests.cs | 0 src/Packages/Audience/Tests/Runtime/{ => Utility}/GzipTests.cs | 0 src/Packages/Audience/Tests/Runtime/{ => Utility}/JsonTests.cs | 0 8 files changed, 0 insertions(+), 0 deletions(-) rename src/Packages/Audience/Tests/Runtime/{ => Events}/MessageBuilderTests.cs (100%) rename src/Packages/Audience/Tests/Runtime/{ => Transport}/DiskStoreTests.cs (100%) rename src/Packages/Audience/Tests/Runtime/{ => Transport}/DiskStoreTests.cs.meta (100%) rename src/Packages/Audience/Tests/Runtime/{ => Transport}/EventQueueTests.cs (100%) rename src/Packages/Audience/Tests/Runtime/{ => Transport}/EventQueueTests.cs.meta (100%) rename src/Packages/Audience/Tests/Runtime/{ => Transport}/HttpTransportTests.cs (100%) rename src/Packages/Audience/Tests/Runtime/{ => Utility}/GzipTests.cs (100%) rename src/Packages/Audience/Tests/Runtime/{ => Utility}/JsonTests.cs (100%) diff --git a/src/Packages/Audience/Tests/Runtime/MessageBuilderTests.cs b/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs similarity index 100% rename from src/Packages/Audience/Tests/Runtime/MessageBuilderTests.cs rename to src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs diff --git a/src/Packages/Audience/Tests/Runtime/DiskStoreTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs similarity index 100% rename from src/Packages/Audience/Tests/Runtime/DiskStoreTests.cs rename to src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs diff --git a/src/Packages/Audience/Tests/Runtime/DiskStoreTests.cs.meta b/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs.meta similarity index 100% rename from src/Packages/Audience/Tests/Runtime/DiskStoreTests.cs.meta rename to src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs.meta diff --git a/src/Packages/Audience/Tests/Runtime/EventQueueTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs similarity index 100% rename from src/Packages/Audience/Tests/Runtime/EventQueueTests.cs rename to src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs diff --git a/src/Packages/Audience/Tests/Runtime/EventQueueTests.cs.meta b/src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs.meta similarity index 100% rename from src/Packages/Audience/Tests/Runtime/EventQueueTests.cs.meta rename to src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs.meta diff --git a/src/Packages/Audience/Tests/Runtime/HttpTransportTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs similarity index 100% rename from src/Packages/Audience/Tests/Runtime/HttpTransportTests.cs rename to src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs diff --git a/src/Packages/Audience/Tests/Runtime/GzipTests.cs b/src/Packages/Audience/Tests/Runtime/Utility/GzipTests.cs similarity index 100% rename from src/Packages/Audience/Tests/Runtime/GzipTests.cs rename to src/Packages/Audience/Tests/Runtime/Utility/GzipTests.cs diff --git a/src/Packages/Audience/Tests/Runtime/JsonTests.cs b/src/Packages/Audience/Tests/Runtime/Utility/JsonTests.cs similarity index 100% rename from src/Packages/Audience/Tests/Runtime/JsonTests.cs rename to src/Packages/Audience/Tests/Runtime/Utility/JsonTests.cs From d759bebe8aa106d6227a8796d90f9030c0a80c0b Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 22 Apr 2026 04:40:11 +1000 Subject: [PATCH 5/7] refactor(audience): centralise message-type and field constants and add Unity build scaffolding Groundwork for the ImmutableAudience singleton and the forthcoming Unity integration layer. Pure refactor - no runtime behaviour change. Constants: - Core/Constants.cs: adds LibraryVersion ("0.1.0") so the message builder and tests share one source of truth; ConsentUrl and DataUrl helpers matching the existing MessagesUrl shape; MessageTypes ("track"/"identify"/"alias") and MessageFields ("type"/"userId") - values that cross module boundaries inside the SDK and were previously stringly-coded in multiple places. - Events/MessageBuilder.cs: swap the six string literals for the new MessageTypes / MessageFields constants. - Tests/Runtime/ConstantsTests.cs: covers the BaseUrl/MessagesUrl/ ConsentUrl/DataUrl sandbox-vs-prod selection. Scaffolding (forward-pointing for the Unity layer that follows): - .gitignore: ignore bin/. - AssemblyInfo: grant internals to Immutable.Audience.Unity. - Audience.Runtime.csproj: exclude Unity/ from the headless dotnet-SDK build so Audience.Tests compiles once the Unity integration lands. No-op until Unity/ files exist. --- src/Packages/Audience/.gitignore | 1 + src/Packages/Audience/Runtime/AssemblyInfo.cs | 1 + .../Audience/Runtime/Audience.Runtime.csproj | 10 ++++-- .../Audience/Runtime/Core/Constants.cs | 19 +++++++++++ .../Audience/Runtime/Events/MessageBuilder.cs | 12 +++---- .../Audience/Tests/Runtime/ConstantsTests.cs | 33 +++++++++++++++++++ 6 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 src/Packages/Audience/.gitignore create mode 100644 src/Packages/Audience/Tests/Runtime/ConstantsTests.cs diff --git a/src/Packages/Audience/.gitignore b/src/Packages/Audience/.gitignore new file mode 100644 index 000000000..e660fd93d --- /dev/null +++ b/src/Packages/Audience/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/src/Packages/Audience/Runtime/AssemblyInfo.cs b/src/Packages/Audience/Runtime/AssemblyInfo.cs index b3806e91b..5f1a3ea03 100644 --- a/src/Packages/Audience/Runtime/AssemblyInfo.cs +++ b/src/Packages/Audience/Runtime/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Immutable.Audience.Runtime.Tests")] +[assembly: InternalsVisibleTo("Immutable.Audience.Unity")] diff --git a/src/Packages/Audience/Runtime/Audience.Runtime.csproj b/src/Packages/Audience/Runtime/Audience.Runtime.csproj index 388f1006b..7fc7f1620 100644 --- a/src/Packages/Audience/Runtime/Audience.Runtime.csproj +++ b/src/Packages/Audience/Runtime/Audience.Runtime.csproj @@ -7,8 +7,12 @@ Immutable.Audience.Runtime + + + diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index ad32356ac..79b9bb858 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -17,12 +17,15 @@ internal static class Constants internal const int MaxFieldLength = 256; // Backend schema limit. internal const string LibraryName = "com.immutable.audience"; + internal const string LibraryVersion = "0.1.0"; internal const string Surface = "unity"; internal const string ConsentSource = "UnitySDK"; internal const string PublishableKeyHeader = "x-immutable-publishable-key"; internal static string MessagesUrl(string publishableKey) => BaseUrl(publishableKey) + MessagesPath; + internal static string ConsentUrl(string publishableKey) => BaseUrl(publishableKey) + ConsentPath; + internal static string DataUrl(string publishableKey) => BaseUrl(publishableKey) + DataPath; internal static string BaseUrl(string publishableKey) => publishableKey != null && publishableKey.StartsWith(TestKeyPrefix) @@ -30,6 +33,22 @@ internal static string BaseUrl(string publishableKey) => : ProductionBaseUrl; } + // Message type values written to (and read back from) the "type" field. + internal static class MessageTypes + { + internal const string Track = "track"; + internal const string Identify = "identify"; + internal const string Alias = "alias"; + } + + // Wire-format field names that cross module boundaries inside the SDK + // (read by one module, written by another). + internal static class MessageFields + { + internal const string Type = "type"; + internal const string UserId = "userId"; + } + // Common distribution platform values for AudienceConfig.DistributionPlatform. public static class DistributionPlatforms { diff --git a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs index 10b20a2cb..bff2dda53 100644 --- a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs +++ b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs @@ -12,14 +12,14 @@ internal static Dictionary Track( string packageVersion, Dictionary properties = null) { - var msg = BuildBase("track", packageVersion); + var msg = BuildBase(MessageTypes.Track, packageVersion); msg["eventName"] = Truncate(eventName, Constants.MaxFieldLength); if (!string.IsNullOrEmpty(anonymousId)) msg["anonymousId"] = Truncate(anonymousId, Constants.MaxFieldLength); if (!string.IsNullOrEmpty(userId)) - msg["userId"] = Truncate(userId, Constants.MaxFieldLength); + msg[MessageFields.UserId] = Truncate(userId, Constants.MaxFieldLength); if (properties != null && properties.Count > 0) msg["properties"] = properties; @@ -34,13 +34,13 @@ internal static Dictionary Identify( string packageVersion, Dictionary traits = null) { - var msg = BuildBase("identify", packageVersion); + var msg = BuildBase(MessageTypes.Identify, packageVersion); if (!string.IsNullOrEmpty(anonymousId)) msg["anonymousId"] = Truncate(anonymousId, Constants.MaxFieldLength); if (!string.IsNullOrEmpty(userId)) - msg["userId"] = Truncate(userId, Constants.MaxFieldLength); + msg[MessageFields.UserId] = Truncate(userId, Constants.MaxFieldLength); if (!string.IsNullOrEmpty(identityType)) msg["identityType"] = Truncate(identityType, Constants.MaxFieldLength); @@ -58,7 +58,7 @@ internal static Dictionary Alias( string toType, string packageVersion) { - var msg = BuildBase("alias", packageVersion); + var msg = BuildBase(MessageTypes.Alias, packageVersion); msg["fromId"] = Truncate(fromId, Constants.MaxFieldLength); msg["fromType"] = Truncate(fromType, Constants.MaxFieldLength); msg["toId"] = Truncate(toId, Constants.MaxFieldLength); @@ -70,7 +70,7 @@ private static Dictionary BuildBase(string type, string packageV { return new Dictionary { - ["type"] = type, + [MessageFields.Type] = type, ["messageId"] = Guid.NewGuid().ToString(), ["eventTimestamp"] = DateTime.UtcNow.ToString("o"), ["context"] = new Dictionary diff --git a/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs b/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs new file mode 100644 index 000000000..b0af0ea8d --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs @@ -0,0 +1,33 @@ +using System.IO; +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class ConstantsTests + { + [Test] + public void LibraryVersion_MatchesPackageJson() + { + // Fails the build if Constants.LibraryVersion and package.json + // "version" drift. context.libraryVersion on every outgoing event + // depends on them agreeing. + var packageJson = ReadPackageJson(); + var parsed = JsonReader.DeserializeObject(packageJson); + + Assert.IsTrue(parsed.TryGetValue("version", out var versionObj), + "package.json is missing a \"version\" field"); + Assert.AreEqual(Constants.LibraryVersion, versionObj, + "Constants.LibraryVersion must match package.json version"); + } + + private static string ReadPackageJson() + { + var testDir = TestContext.CurrentContext.TestDirectory; + // Tests/bin/Debug/net8.0/ → Tests/ → Audience/package.json + var packagePath = Path.GetFullPath(Path.Combine(testDir, "..", "..", "..", "..", "package.json")); + Assert.IsTrue(File.Exists(packagePath), $"package.json not found at {packagePath}"); + return File.ReadAllText(packagePath); + } + } +} From a34f779ea6c17964f052584ac20afdb262c49ce0 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 22 Apr 2026 04:40:41 +1000 Subject: [PATCH 6/7] feat(audience): add ConsentStore for atomic consent persistence Introduces Core/ConsentStore, a tiny static wrapper that reads and writes the user's ConsentLevel to disk under {persistentDataPath}/imtbl_audience/consent. Save uses the same write-temp-then-move pattern as DiskStore and Identity so a crash mid-write cannot leave a half-written consent level on disk; Load returns null on missing, malformed, or unreadable files so callers can fall back to the configured default. No production callers yet. ImmutableAudience will consume this in the next commit to persist SetConsent decisions across launches. Tests/Runtime/Core/ConsentStoreTests covers the round-trip, the null return for missing/corrupt/out-of-range files, and the behaviour when the backing directory does not yet exist. --- .../Audience/Runtime/Core/ConsentStore.cs | 50 +++++++++++++ .../Tests/Runtime/Core/ConsentStoreTests.cs | 70 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 src/Packages/Audience/Runtime/Core/ConsentStore.cs create mode 100644 src/Packages/Audience/Tests/Runtime/Core/ConsentStoreTests.cs diff --git a/src/Packages/Audience/Runtime/Core/ConsentStore.cs b/src/Packages/Audience/Runtime/Core/ConsentStore.cs new file mode 100644 index 000000000..e0832df9a --- /dev/null +++ b/src/Packages/Audience/Runtime/Core/ConsentStore.cs @@ -0,0 +1,50 @@ +using System; +using System.IO; + +namespace Immutable.Audience +{ + internal static class ConsentStore + { + internal static void Save(string persistentDataPath, ConsentLevel level) + { + Directory.CreateDirectory(AudiencePaths.AudienceDir(persistentDataPath)); + + var filePath = AudiencePaths.ConsentFile(persistentDataPath); + var tmpPath = filePath + ".tmp"; + + File.WriteAllText(tmpPath, ((int)level).ToString()); + + try + { + File.Move(tmpPath, filePath); + } + catch (IOException) + { + File.Delete(filePath); + File.Move(tmpPath, filePath); + } + } + + // Returns null on missing/malformed/unreadable file; caller falls back to config default. + internal static ConsentLevel? Load(string persistentDataPath) + { + try + { + var filePath = AudiencePaths.ConsentFile(persistentDataPath); + if (!File.Exists(filePath)) return null; + + var text = File.ReadAllText(filePath).Trim(); + if (int.TryParse(text, out var raw) && Enum.IsDefined(typeof(ConsentLevel), raw)) + return (ConsentLevel)raw; + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + + return null; + } + } +} diff --git a/src/Packages/Audience/Tests/Runtime/Core/ConsentStoreTests.cs b/src/Packages/Audience/Tests/Runtime/Core/ConsentStoreTests.cs new file mode 100644 index 000000000..85713b777 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/Core/ConsentStoreTests.cs @@ -0,0 +1,70 @@ +using System.IO; +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class ConsentStoreTests + { + private string _testDir; + + [SetUp] + public void SetUp() + { + _testDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_testDir); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_testDir)) + Directory.Delete(_testDir, recursive: true); + } + + [Test] + public void Load_NoFile_ReturnsNull() + { + Assert.IsNull(ConsentStore.Load(_testDir)); + } + + [Test] + public void SaveThenLoad_RoundtripsValue([Values] ConsentLevel level) + { + ConsentStore.Save(_testDir, level); + Assert.AreEqual(level, ConsentStore.Load(_testDir)); + } + + [Test] + public void Save_OverwritesPreviousValue() + { + ConsentStore.Save(_testDir, ConsentLevel.Anonymous); + ConsentStore.Save(_testDir, ConsentLevel.Full); + + Assert.AreEqual(ConsentLevel.Full, ConsentStore.Load(_testDir)); + } + + [Test] + public void Load_MalformedFile_ReturnsNull() + { + // A garbage value that isn't a valid enum int. + var dir = AudiencePaths.AudienceDir(_testDir); + Directory.CreateDirectory(dir); + File.WriteAllText(AudiencePaths.ConsentFile(_testDir), "not-an-int"); + + Assert.IsNull(ConsentStore.Load(_testDir)); + } + + [Test] + public void Load_OutOfRangeIntInFile_ReturnsNull() + { + // 999 is parseable as int but not a defined ConsentLevel. + var dir = AudiencePaths.AudienceDir(_testDir); + Directory.CreateDirectory(dir); + File.WriteAllText(AudiencePaths.ConsentFile(_testDir), "999"); + + Assert.IsNull(ConsentStore.Load(_testDir)); + } + + } +} From 2268fba350072ff28f007da43557535f0b91da29 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 22 Apr 2026 10:07:40 +1000 Subject: [PATCH 7/7] refactor(audience): enable nullable reference types on Slice 2 files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies #nullable enable + targeted `?` annotations per the HttpTransport precedent (PR 691). Scope: the six files introduced or substantially modified in Slice 2. - IEvent.cs: no annotations needed; impl returns non-null. - IdentityType.cs: ToLowercaseString return → string? (existing comment documents null-on-unknown-cast contract). - TypedEvents.cs: optional properties → string?; also tightens IsIso4217 to non-null param (Currency gets an explicit null-or-invalid check before dict assignment). Adds a Resource.Currency null/empty validation that was missing. - Core/ConsentStore.cs: no annotations needed. - Core/Constants.cs: BaseUrl/MessagesUrl/ConsentUrl/DataUrl params → string? (code already guards with `publishableKey != null`). - Events/MessageBuilder.cs: Track/Identify guarded params (anonymousId, userId, identityType) → string?; properties/traits → Dictionary?. Truncate tightened to non-null input (callers guard before the call). Compile-time annotations only. Zero runtime behaviour change except Resource.Currency now throws on null/empty, matching the "Required" comment on the field. --- .../Audience/Runtime/Core/ConsentStore.cs | 2 ++ .../Audience/Runtime/Core/Constants.cs | 10 +++--- .../Audience/Runtime/Core/Identity.cs | 8 +++-- .../Audience/Runtime/Events/IEvent.cs | 2 ++ .../Audience/Runtime/Events/MessageBuilder.cs | 35 ++++++++++++++----- .../Audience/Runtime/Events/TypedEvents.cs | 31 +++++++++------- src/Packages/Audience/Runtime/IdentityType.cs | 4 ++- 7 files changed, 63 insertions(+), 29 deletions(-) diff --git a/src/Packages/Audience/Runtime/Core/ConsentStore.cs b/src/Packages/Audience/Runtime/Core/ConsentStore.cs index e0832df9a..8701a56e2 100644 --- a/src/Packages/Audience/Runtime/Core/ConsentStore.cs +++ b/src/Packages/Audience/Runtime/Core/ConsentStore.cs @@ -1,3 +1,5 @@ +#nullable enable + using System; using System.IO; diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index 79b9bb858..9cb2a6de0 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -1,3 +1,5 @@ +#nullable enable + namespace Immutable.Audience { internal static class Constants @@ -23,11 +25,11 @@ internal static class Constants internal const string PublishableKeyHeader = "x-immutable-publishable-key"; - internal static string MessagesUrl(string publishableKey) => BaseUrl(publishableKey) + MessagesPath; - internal static string ConsentUrl(string publishableKey) => BaseUrl(publishableKey) + ConsentPath; - internal static string DataUrl(string publishableKey) => BaseUrl(publishableKey) + DataPath; + internal static string MessagesUrl(string? publishableKey) => BaseUrl(publishableKey) + MessagesPath; + internal static string ConsentUrl(string? publishableKey) => BaseUrl(publishableKey) + ConsentPath; + internal static string DataUrl(string? publishableKey) => BaseUrl(publishableKey) + DataPath; - internal static string BaseUrl(string publishableKey) => + internal static string BaseUrl(string? publishableKey) => publishableKey != null && publishableKey.StartsWith(TestKeyPrefix) ? SandboxBaseUrl : ProductionBaseUrl; diff --git a/src/Packages/Audience/Runtime/Core/Identity.cs b/src/Packages/Audience/Runtime/Core/Identity.cs index aa8da17dc..8ae1f6778 100644 --- a/src/Packages/Audience/Runtime/Core/Identity.cs +++ b/src/Packages/Audience/Runtime/Core/Identity.cs @@ -1,3 +1,5 @@ +#nullable enable + using System; using System.IO; @@ -12,12 +14,12 @@ namespace Immutable.Audience internal sealed class Identity { // In-memory cache — volatile so background threads always see the latest write. - private static volatile string _cachedId; + private static volatile string? _cachedId; private static readonly object _sync = new object(); // Returns the existing anonymous ID, or null if none exists. // Unlike GetOrCreate, never generates or persists a new one. - internal static string Get(string persistentDataPath) + internal static string? Get(string persistentDataPath) { if (_cachedId != null) return _cachedId; @@ -58,7 +60,7 @@ internal static void ClearCache() // 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. - internal static string GetOrCreate(string persistentDataPath, ConsentLevel consent) + internal static string? GetOrCreate(string persistentDataPath, ConsentLevel consent) { // No ID until the player grants at least anonymous consent. if (consent == ConsentLevel.None) diff --git a/src/Packages/Audience/Runtime/Events/IEvent.cs b/src/Packages/Audience/Runtime/Events/IEvent.cs index 15961c2cb..0ef5a28d0 100644 --- a/src/Packages/Audience/Runtime/Events/IEvent.cs +++ b/src/Packages/Audience/Runtime/Events/IEvent.cs @@ -1,3 +1,5 @@ +#nullable enable + using System.Collections.Generic; namespace Immutable.Audience diff --git a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs index bff2dda53..1347e4665 100644 --- a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs +++ b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs @@ -1,3 +1,5 @@ +#nullable enable + using System; using System.Collections.Generic; @@ -7,10 +9,10 @@ internal static class MessageBuilder { internal static Dictionary Track( string eventName, - string anonymousId, - string userId, + string? anonymousId, + string? userId, string packageVersion, - Dictionary properties = null) + Dictionary? properties = null) { var msg = BuildBase(MessageTypes.Track, packageVersion); msg["eventName"] = Truncate(eventName, Constants.MaxFieldLength); @@ -22,17 +24,20 @@ internal static Dictionary Track( msg[MessageFields.UserId] = Truncate(userId, Constants.MaxFieldLength); if (properties != null && properties.Count > 0) + { + TruncateStringValues(properties); msg["properties"] = properties; + } return msg; } internal static Dictionary Identify( - string anonymousId, - string userId, - string identityType, + string? anonymousId, + string? userId, + string? identityType, string packageVersion, - Dictionary traits = null) + Dictionary? traits = null) { var msg = BuildBase(MessageTypes.Identify, packageVersion); @@ -46,7 +51,10 @@ internal static Dictionary Identify( msg["identityType"] = Truncate(identityType, Constants.MaxFieldLength); if (traits != null && traits.Count > 0) + { + TruncateStringValues(traits); msg["traits"] = traits; + } return msg; } @@ -84,9 +92,20 @@ private static Dictionary BuildBase(string type, string packageV private static string Truncate(string s, int maxLen) { - if (s == null || s.Length <= maxLen) + if (s.Length <= maxLen) return s; return s.Substring(0, maxLen); } + + private static void TruncateStringValues(Dictionary dict) + { + // Snapshot keys to avoid mutating the collection during iteration. + var keys = new List(dict.Keys); + foreach (var key in keys) + { + if (dict[key] is string s && s.Length > Constants.MaxFieldLength) + dict[key] = Truncate(s, Constants.MaxFieldLength); + } + } } } diff --git a/src/Packages/Audience/Runtime/Events/TypedEvents.cs b/src/Packages/Audience/Runtime/Events/TypedEvents.cs index 82d779264..36d35d2e0 100644 --- a/src/Packages/Audience/Runtime/Events/TypedEvents.cs +++ b/src/Packages/Audience/Runtime/Events/TypedEvents.cs @@ -1,3 +1,5 @@ +#nullable enable + using System; using System.Collections.Generic; @@ -31,9 +33,9 @@ public class Progression : IEvent // Required. public ProgressionStatus Status { get; set; } // Optional. - public string World { get; set; } - public string Level { get; set; } - public string Stage { get; set; } + public string? World { get; set; } + public string? Level { get; set; } + public string? Stage { get; set; } public int? Score { get; set; } public float? DurationSec { get; set; } @@ -81,16 +83,19 @@ public class Resource : IEvent { // Required. public ResourceFlow Flow { get; set; } - public string Currency { get; set; } + public string? Currency { get; set; } public float Amount { get; set; } // Optional. - public string ItemType { get; set; } - public string ItemId { get; set; } + public string? ItemType { get; set; } + public string? ItemId { get; set; } public string EventName => "resource"; public Dictionary ToProperties() { + if (string.IsNullOrEmpty(Currency)) + throw new ArgumentException("Resource.Currency must not be null or empty"); + var props = new Dictionary { ["flow"] = Flow.ToLowercaseString(), @@ -109,21 +114,21 @@ public Dictionary ToProperties() public class Purchase : IEvent { // Required. ISO 4217 three-letter uppercase currency code. - public string Currency { get; set; } + public string? Currency { get; set; } // Required. public decimal Value { get; set; } // Optional. - public string ItemId { get; set; } - public string ItemName { get; set; } + public string? ItemId { get; set; } + public string? ItemName { get; set; } public int? Quantity { get; set; } - public string TransactionId { get; set; } + public string? TransactionId { get; set; } public string EventName => "purchase"; // Hand-rolled to avoid pulling System.Text.RegularExpressions into the IL2CPP build. private static bool IsIso4217(string s) { - if (s == null || s.Length != 3) return false; + if (s.Length != 3) return false; for (var i = 0; i < 3; i++) { var c = s[i]; @@ -134,7 +139,7 @@ private static bool IsIso4217(string s) public Dictionary ToProperties() { - if (!IsIso4217(Currency)) + if (Currency == null || !IsIso4217(Currency)) throw new ArgumentException( $"Purchase.Currency '{Currency}' must be a three-letter uppercase ISO 4217 code"); @@ -157,7 +162,7 @@ public Dictionary ToProperties() public class MilestoneReached : IEvent { // Required. - public string Name { get; set; } + public string? Name { get; set; } public string EventName => "milestone_reached"; diff --git a/src/Packages/Audience/Runtime/IdentityType.cs b/src/Packages/Audience/Runtime/IdentityType.cs index 172eefc7b..eed0312ea 100644 --- a/src/Packages/Audience/Runtime/IdentityType.cs +++ b/src/Packages/Audience/Runtime/IdentityType.cs @@ -1,3 +1,5 @@ +#nullable enable + namespace Immutable.Audience { // Identity provider accepted by the Audience backend. @@ -18,7 +20,7 @@ internal static class IdentityTypeExtensions // Returns null on unknown casts. The string overloads of Identify / // Alias check for null/empty and drop + warn, so an out-of-range // cast surfaces as a dropped event, not a corrupt wire payload. - internal static string ToLowercaseString(this IdentityType type) => type switch + internal static string? ToLowercaseString(this IdentityType type) => type switch { IdentityType.Passport => "passport", IdentityType.Steam => "steam",