Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions src/Packages/Audience/Runtime/Core/Identity.cs
Original file line number Diff line number Diff line change
@@ -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.
}
}
}
}
}
83 changes: 83 additions & 0 deletions src/Packages/Audience/Tests/Runtime/IdentityTests.cs
Original file line number Diff line number Diff line change
@@ -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");
}
}
}
Loading