diff --git a/src/Packages/Audience/Runtime/Core/Identity.cs b/src/Packages/Audience/Runtime/Core/Identity.cs new file mode 100644 index 000000000..70033f4c7 --- /dev/null +++ b/src/Packages/Audience/Runtime/Core/Identity.cs @@ -0,0 +1,100 @@ +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. + // + // Note: _cachedId is a static field. In the Unity Editor with domain reload disabled, + // it persists across play sessions. ImmutableAudience.Init() is responsible for calling + // Reset() at startup to ensure a clean state in that scenario. + 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 ImmutableAudience.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) + { + lock (_sync) + { + _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..1518d62d6 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/IdentityTests.cs @@ -0,0 +1,83 @@ +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 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"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "identity"), expectedId); + + var result = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + + Assert.AreEqual(expectedId, result); + } + + [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"); + } + } +}