From 2111d4c62ddb3fe9c3a7eb0fe02de54fdc478f0e Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Wed, 15 Apr 2026 12:16:26 +1200 Subject: [PATCH] feat(audience): add Identity module (SDK-126) --- .../Audience/Runtime/Core/Identity.cs | 93 +++++++++++++++++++ .../Audience/Tests/Runtime/IdentityTests.cs | 69 ++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 src/Packages/Audience/Runtime/Core/Identity.cs create mode 100644 src/Packages/Audience/Tests/Runtime/IdentityTests.cs diff --git a/src/Packages/Audience/Runtime/Core/Identity.cs b/src/Packages/Audience/Runtime/Core/Identity.cs new file mode 100644 index 000000000..9e3241fdd --- /dev/null +++ b/src/Packages/Audience/Runtime/Core/Identity.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; + +namespace Immutable.Audience +{ + // Manages the anonymous ID that identifies a device across sessions. + // The ID is a UUID generated once, written to disk, and reused on every subsequent launch. + internal sealed class Identity + { + // In-memory cache — volatile so background threads always see the latest write. + 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 Init() has run on the main thread. + internal static string GetOrCreate(string persistentDataPath, ConsentLevel consent) + { + // No ID until the player grants at least anonymous consent. + if (consent == ConsentLevel.None) + return null; + + // Fast path — already loaded this session, no lock needed. + if (_cachedId != null) + return _cachedId; + + // Slow path — first call or after Reset(). Only one thread does the work. + lock (_sync) + { + // Re-check after acquiring the lock in case another thread beat us here. + if (_cachedId != null) + return _cachedId; + + var dir = GetDirectory(persistentDataPath); + Directory.CreateDirectory(dir); // no-op if already exists + + var filePath = GetFilePath(persistentDataPath); + + // Returning player — read the ID we wrote on a previous launch. + if (File.Exists(filePath)) + { + _cachedId = File.ReadAllText(filePath).Trim(); + return _cachedId; + } + + // New install — generate a UUID and persist it atomically. + // Write to a .tmp file first so a crash mid-write leaves no corrupt file. + var newId = Guid.NewGuid().ToString(); + var tmpPath = filePath + ".tmp"; + File.WriteAllText(tmpPath, newId); + + try + { + File.Move(tmpPath, filePath); + } + catch (IOException) + { + // Unexpected — file appeared between our Exists check and Move (shouldn't happen in practice). + // Delete and retry to ensure a clean state. + File.Delete(filePath); + File.Move(tmpPath, filePath); + } + + _cachedId = newId; + return _cachedId; + } + } + + // Clears the cached ID and deletes the persisted file. + // Called on logout or when consent is downgraded to None. + // The next GetOrCreate call will generate a fresh ID. + internal static void Reset(string persistentDataPath) + { + _cachedId = null; + + var filePath = GetFilePath(persistentDataPath); + try + { + File.Delete(filePath); + } + catch (Exception e) when (e is FileNotFoundException || e is DirectoryNotFoundException) + { + // File was never written (e.g. consent was None) — nothing to do. + } + } + } +} diff --git a/src/Packages/Audience/Tests/Runtime/IdentityTests.cs b/src/Packages/Audience/Tests/Runtime/IdentityTests.cs new file mode 100644 index 000000000..67aa4620f --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/IdentityTests.cs @@ -0,0 +1,69 @@ +using System.IO; +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class IdentityTests + { + private string _testDir; + + [SetUp] + public void SetUp() + { + _testDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_testDir); + } + + [TearDown] + public void TearDown() + { + Identity.Reset(_testDir); + if (Directory.Exists(_testDir)) + Directory.Delete(_testDir, recursive: true); + } + + [Test] + public void NewDirectory_GeneratesNonEmptyId_AndWritesFile() + { + var id = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + + Assert.IsNotNull(id); + Assert.IsNotEmpty(id); + + var filePath = Path.Combine(_testDir, "imtbl_audience", "identity"); + Assert.IsTrue(File.Exists(filePath), "identity file should exist on disk"); + } + + [Test] + public void SecondCall_ReturnsSameId() + { + var id1 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + var id2 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + + Assert.AreEqual(id1, id2); + } + + [Test] + public void Reset_NextCallReturnsDifferentId() + { + var id1 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + Identity.Reset(_testDir); + var id2 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + + Assert.IsNotNull(id2); + Assert.AreNotEqual(id1, id2); + } + + [Test] + public void ConsentNone_ReturnsNull_AndNoFileWritten() + { + var id = Identity.GetOrCreate(_testDir, ConsentLevel.None); + + Assert.IsNull(id); + + var filePath = Path.Combine(_testDir, "imtbl_audience", "identity"); + Assert.IsFalse(File.Exists(filePath), "identity file must not be written when consent is None"); + } + } +}