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
1 change: 1 addition & 0 deletions src/Packages/Audience/Runtime/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ internal static class MessageFields
{
internal const string Type = "type";
internal const string UserId = "userId";
internal const string DeviceId = "deviceId";
}

/// <summary>
Expand Down
227 changes: 161 additions & 66 deletions src/Packages/Audience/Runtime/Core/Identity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,132 +5,227 @@

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.
// Manages the anonymous ID and device ID for this device.
// Both are UUIDs persisted to {"anonymousId":"<uuid>","deviceId":"<uuid>"}.
// deviceId survives RotateAnonymousId (logout); both are wiped by Reset (opt-out).
//
// 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.
// Static caches persist across play sessions in the Unity Editor with domain reload
// disabled. ImmutableAudience.Init() calls ClearCache() via ResetState() to handle that.
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? _cachedAnonId;
private static volatile string? _cachedDeviceId;
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.
// Returns the existing anonymous ID without creating one. Used by DeleteData.
internal static string? Get(string persistentDataPath)
{
if (_cachedId != null) return _cachedId;
if (_cachedAnonId != null) return _cachedAnonId;

lock (_sync)
{
if (_cachedId != null) return _cachedId;
if (_cachedAnonId != null) return _cachedAnonId;

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;
var content = File.ReadAllText(filePath).Trim();
ParseFile(content, out var anonId, out _);
_cachedAnonId = anonId;
return _cachedAnonId;
}
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
// Clears both in-memory caches 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;
_cachedAnonId = null;
_cachedDeviceId = 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.
// Returns the anonymous ID, generating and persisting both IDs on first call.
// Returns null when consent is None.
internal static string? GetOrCreate(string persistentDataPath, ConsentLevel consent)
{
// No ID until the player grants at least anonymous consent.
if (!consent.CanTrack())
return null;
if (!consent.CanTrack()) return null;

// Fast path: already loaded this session, no lock needed.
if (_cachedId != null)
return _cachedId;
if (_cachedAnonId != null) return _cachedAnonId;

// 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;
if (_cachedAnonId != null) return _cachedAnonId;

var dir = AudiencePaths.AudienceDir(persistentDataPath);
Directory.CreateDirectory(dir); // no-op if already exists
LoadOrGenerate(persistentDataPath);
return _cachedAnonId;
}
}

var filePath = AudiencePaths.IdentityFile(persistentDataPath);
// Returns the device ID at Anonymous+ consent, null at None.
internal static string? GetOrCreateDeviceId(string persistentDataPath, ConsentLevel consent)
{
if (!consent.CanTrack()) return null;

// Returning player: read the ID we wrote on a previous launch.
if (File.Exists(filePath))
if (_cachedDeviceId != null) return _cachedDeviceId;

lock (_sync)
{
if (_cachedDeviceId != null) return _cachedDeviceId;

LoadOrGenerate(persistentDataPath);
return _cachedDeviceId;
}
}

// Logout: rotates anon_id and rewrites the file, preserving device_id.
internal static void RotateAnonymousId(string persistentDataPath)
{
lock (_sync)
{
var deviceId = _cachedDeviceId;
if (deviceId == null)
{
_cachedId = File.ReadAllText(filePath).Trim();
return _cachedId;
try
{
var fp = AudiencePaths.IdentityFile(persistentDataPath);
if (File.Exists(fp))
{
ParseFile(File.ReadAllText(fp).Trim(), out _, out deviceId);
}
}
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { }
}

// 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);
_cachedAnonId = null;

if (string.IsNullOrEmpty(deviceId))
{
// Nothing to preserve: delete so the next GetOrCreate regenerates both fresh.
try { File.Delete(AudiencePaths.IdentityFile(persistentDataPath)); }
catch (Exception ex) when (ex is FileNotFoundException || ex is DirectoryNotFoundException) { }
return;
}

// Rewrite with a new anon_id, same device_id.
var newAnonId = Guid.NewGuid().ToString();
try
{
File.Move(tmpPath, filePath);
WriteFile(AudiencePaths.IdentityFile(persistentDataPath), newAnonId, deviceId);
_cachedAnonId = newAnonId;
}
catch (IOException)
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
{
// 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);
Log.Warn(AudienceLogs.IdentityRotateFailed(ex));
}

_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.
// Full wipe: clears both IDs and deletes the file. Called on SetConsent(None).
internal static void Reset(string persistentDataPath)
{
lock (_sync)
{
_cachedId = null;
_cachedAnonId = null;
_cachedDeviceId = null;

var filePath = AudiencePaths.IdentityFile(persistentDataPath);
try
try { File.Delete(filePath); }
catch (Exception ex) when (ex is FileNotFoundException || ex is DirectoryNotFoundException) { }
}
}

// Slow path: read from disk or generate fresh IDs. Must be called under _sync.
private static void LoadOrGenerate(string persistentDataPath)
{
try
{
var dir = AudiencePaths.AudienceDir(persistentDataPath);
Directory.CreateDirectory(dir);

var filePath = AudiencePaths.IdentityFile(persistentDataPath);

if (File.Exists(filePath))
{
var content = File.ReadAllText(filePath).Trim();
ParseFile(content, out var existingAnonId, out var existingDeviceId);

if (!string.IsNullOrEmpty(existingAnonId) && !string.IsNullOrEmpty(existingDeviceId))
{
_cachedAnonId = existingAnonId;
_cachedDeviceId = existingDeviceId;
return;
}

// Partial or old plain-string format: keep anon_id, generate device_id, migrate file.
var anonId = string.IsNullOrEmpty(existingAnonId) ? Guid.NewGuid().ToString() : existingAnonId;
var deviceId = Guid.NewGuid().ToString();
WriteFile(filePath, anonId, deviceId);
_cachedAnonId = anonId;
_cachedDeviceId = deviceId;
return;
}

{
File.Delete(filePath);
var anonId = Guid.NewGuid().ToString();
var deviceId = Guid.NewGuid().ToString();
WriteFile(filePath, anonId, deviceId);
_cachedAnonId = anonId;
_cachedDeviceId = deviceId;
}
catch (Exception e) when (e is FileNotFoundException || e is DirectoryNotFoundException)
}
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
{
Log.Warn(AudienceLogs.IdentityLoadOrGenerateFailed(ex));
}
}

// Handles two formats: JSON {"anonymousId":...,"deviceId":...} and the legacy plain UUID string.
private static void ParseFile(string content, out string? anonId, out string? deviceId)
{
anonId = null;
deviceId = null;

if (content.StartsWith("{"))
{
try
{
// File was never written (e.g. consent was None). Nothing to do.
var obj = JsonReader.DeserializeObject(content);
obj.TryGetValue("anonymousId", out var a);
obj.TryGetValue("deviceId", out var d);
anonId = a as string;
deviceId = d as string;
return;
}
catch (Exception) { }
}

// Legacy plain-UUID format.
if (!string.IsNullOrEmpty(content))
anonId = content;
}

private static void WriteFile(string filePath, string anonId, string deviceId)
{
var json = Json.Serialize(new System.Collections.Generic.Dictionary<string, object>
{
["anonymousId"] = anonId,
["deviceId"] = deviceId,
});
var tmpPath = filePath + ".tmp";
File.WriteAllText(tmpPath, json);
if (File.Exists(filePath))
File.Replace(tmpPath, filePath, null); // atomic overwrite; no window where the file is absent
else
File.Move(tmpPath, filePath);
}
}
}
13 changes: 13 additions & 0 deletions src/Packages/Audience/Runtime/Events/MessageBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ internal static Dictionary<string, object> Track(
string eventName,
string? anonymousId,
string? userId,
string? deviceId,
string packageVersion,
Dictionary<string, object>? properties = null,
bool testMode = false)
Expand All @@ -24,6 +25,9 @@ internal static Dictionary<string, object> Track(
if (!string.IsNullOrEmpty(userId))
msg[MessageFields.UserId] = Truncate(userId, Constants.MaxFieldLength);

if (!string.IsNullOrEmpty(deviceId))
msg[MessageFields.DeviceId] = Truncate(deviceId, Constants.MaxFieldLength);

if (properties != null && properties.Count > 0)
{
TruncateStringValues(properties);
Expand All @@ -36,6 +40,7 @@ internal static Dictionary<string, object> Track(
internal static Dictionary<string, object> Identify(
string? anonymousId,
string? userId,
string? deviceId,
string identityType,
string packageVersion,
Dictionary<string, object>? traits = null,
Expand All @@ -49,6 +54,9 @@ internal static Dictionary<string, object> Identify(
if (!string.IsNullOrEmpty(userId))
msg[MessageFields.UserId] = Truncate(userId, Constants.MaxFieldLength);

if (!string.IsNullOrEmpty(deviceId))
msg[MessageFields.DeviceId] = Truncate(deviceId, Constants.MaxFieldLength);

msg["identityType"] = Truncate(identityType, Constants.MaxFieldLength);

if (traits != null && traits.Count > 0)
Expand All @@ -65,6 +73,7 @@ internal static Dictionary<string, object> Alias(
string fromType,
string toId,
string toType,
string? deviceId,
string packageVersion,
bool testMode = false)
{
Expand All @@ -73,6 +82,10 @@ internal static Dictionary<string, object> Alias(
msg["fromType"] = Truncate(fromType, Constants.MaxFieldLength);
msg["toId"] = Truncate(toId, Constants.MaxFieldLength);
msg["toType"] = Truncate(toType, Constants.MaxFieldLength);

if (!string.IsNullOrEmpty(deviceId))
msg[MessageFields.DeviceId] = Truncate(deviceId, Constants.MaxFieldLength);

return msg;
}

Expand Down
Loading
Loading