diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index 3645dd06..0694e845 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -52,6 +52,7 @@ internal static class MessageFields { internal const string Type = "type"; internal const string UserId = "userId"; + internal const string DeviceId = "deviceId"; } /// diff --git a/src/Packages/Audience/Runtime/Core/Identity.cs b/src/Packages/Audience/Runtime/Core/Identity.cs index 3c2e1943..0d987576 100644 --- a/src/Packages/Audience/Runtime/Core/Identity.cs +++ b/src/Packages/Audience/Runtime/Core/Identity.cs @@ -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":"","deviceId":""}. + // 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 + { + ["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); } } } diff --git a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs index 4a1de52b..b080532d 100644 --- a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs +++ b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs @@ -11,6 +11,7 @@ internal static Dictionary Track( string eventName, string? anonymousId, string? userId, + string? deviceId, string packageVersion, Dictionary? properties = null, bool testMode = false) @@ -24,6 +25,9 @@ internal static Dictionary 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); @@ -36,6 +40,7 @@ internal static Dictionary Track( internal static Dictionary Identify( string? anonymousId, string? userId, + string? deviceId, string identityType, string packageVersion, Dictionary? traits = null, @@ -49,6 +54,9 @@ internal static Dictionary 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) @@ -65,6 +73,7 @@ internal static Dictionary Alias( string fromType, string toId, string toType, + string? deviceId, string packageVersion, bool testMode = false) { @@ -73,6 +82,10 @@ internal static Dictionary 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; } diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 50f6dd01..85d371fb 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -129,6 +129,27 @@ public static string? AnonymousId } } + /// + /// A stable per-device ID that survives (logout). + /// + /// + /// Generated once on first init at Anonymous+ consent and persisted across launches. + /// Survives so the CDP can resolve a returning player without a + /// re-identify. Wiped on (opt-out). App-generated UUID, + /// not a hardware fingerprint. Null while consent is None. + /// + public static string? DeviceId + { + get + { + if (!_initialized) return null; + var config = _config; + var level = _state.Level; + if (config == null || !level.CanTrack()) return null; + return Identity.GetOrCreateDeviceId(config.PersistentDataPath!, level); + } + } + /// /// The current session's ID. /// @@ -330,9 +351,10 @@ public static void Track(IEvent evt) } var anonymousId = Identity.GetOrCreate(config.PersistentDataPath!, state.Level); + var deviceId = Identity.GetOrCreateDeviceId(config.PersistentDataPath!, state.Level); // ToProperties returns a fresh dict per call, so no snapshot needed. var userId = state.Level == ConsentLevel.Full ? state.UserId : null; - var msg = MessageBuilder.Track(eventName, anonymousId, userId, Constants.LibraryVersion, properties, config.TestMode); + var msg = MessageBuilder.Track(eventName, anonymousId, userId, deviceId, Constants.LibraryVersion, properties, config.TestMode); EnqueueTrack(msg); } @@ -357,8 +379,9 @@ public static void Track(string eventName, Dictionary? propertie if (config == null) return; var anonymousId = Identity.GetOrCreate(config.PersistentDataPath!, state.Level); + var deviceId = Identity.GetOrCreateDeviceId(config.PersistentDataPath!, state.Level); var userId = state.Level == ConsentLevel.Full ? state.UserId : null; - var msg = MessageBuilder.Track(eventName, anonymousId, userId, Constants.LibraryVersion, + var msg = MessageBuilder.Track(eventName, anonymousId, userId, deviceId, Constants.LibraryVersion, SnapshotCallerDict(properties), config.TestMode); EnqueueTrack(msg); } @@ -404,7 +427,8 @@ public static void Identify(string userId, IdentityType identityType, Dictionary } var anonymousId = Identity.GetOrCreate(config.PersistentDataPath!, level); - var msg = MessageBuilder.Identify(anonymousId, userId, identityType.ToLowercaseString(), + var deviceId = Identity.GetOrCreateDeviceId(config.PersistentDataPath!, level); + var msg = MessageBuilder.Identify(anonymousId, userId, deviceId, identityType.ToLowercaseString(), Constants.LibraryVersion, SnapshotCallerDict(traits), config.TestMode); EnqueueIdentity(msg); } @@ -435,8 +459,9 @@ public static void Alias(string fromId, IdentityType fromType, string toId, Iden var config = _config; if (config == null) return; + var deviceId = Identity.GetOrCreateDeviceId(config.PersistentDataPath!, state.Level); var msg = MessageBuilder.Alias(fromId, fromType.ToLowercaseString(), toId, toType.ToLowercaseString(), - Constants.LibraryVersion, config.TestMode); + deviceId, Constants.LibraryVersion, config.TestMode); EnqueueIdentity(msg); } @@ -471,13 +496,11 @@ public static void Reset() newSession = _session; } - // Phase 2 outside _initLock. Order: Dispose enqueues session_end → - // PurgeAll wipes it → Identity.Reset clears the anonymousId file → - // Start emits the new session_start against the fresh id. Matches - // the in-lock sequence this replaces. + // Phase 2 outside _initLock: Dispose enqueues session_end, PurgeAll wipes it, + // RotateAnonymousId rotates anon_id (device_id kept), Start emits the new session_start. oldSession?.Dispose(); queueForPurge?.PurgeAll(); - Identity.Reset(config.PersistentDataPath!); + Identity.RotateAnonymousId(config.PersistentDataPath!); newSession?.Start(); } @@ -1290,7 +1313,7 @@ private static void TryIdentifyEpicUser() } // Reads the EOS EpicAccountId via AuthInterface.GetLoggedInAccountByIndex(0). - // EpicAccountId is the player's Epic Games Account — consistent across all products. + // EpicAccountId is the player's Epic Games Account, consistent across all products. // Requires the game to have already initialised EOS via EOSManager. private static bool TryGetEpicAccountId(out string? id) { diff --git a/src/Packages/Audience/Runtime/Utility/Log.cs b/src/Packages/Audience/Runtime/Utility/Log.cs index 7285c5e4..94104ae9 100644 --- a/src/Packages/Audience/Runtime/Utility/Log.cs +++ b/src/Packages/Audience/Runtime/Utility/Log.cs @@ -159,6 +159,15 @@ internal static string GAIDFetchThrew(Exception ex) => $"GAID fetch threw {ex.GetType().Name}: {ex.Message}. " + "gaid will not ship on game_launch this session; next launch retries."; + // ---- Identity ---- + + internal static string IdentityRotateFailed(Exception ex) => + $"RotateAnonymousId: failed to rewrite identity file. {ex.GetType().Name}: {ex.Message}"; + + internal static string IdentityLoadOrGenerateFailed(Exception ex) => + $"Identity file read/write failed. {ex.GetType().Name}: {ex.Message}. " + + "Events will ship without identity fields this session."; + // ---- Steam auto-detection ---- internal static string SteamPlatformDetectionFailed(Exception ex) => diff --git a/src/Packages/Audience/Tests/Runtime/Core/IdentityTests.cs b/src/Packages/Audience/Tests/Runtime/Core/IdentityTests.cs index cd2cf5fd..d0e87abf 100644 --- a/src/Packages/Audience/Tests/Runtime/Core/IdentityTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Core/IdentityTests.cs @@ -23,6 +23,10 @@ public void TearDown() Directory.Delete(_testDir, recursive: true); } + // ----------------------------------------------------------------- + // GetOrCreate: anonymous ID + // ----------------------------------------------------------------- + [Test] public void NewDirectory_GeneratesNonEmptyId_AndWritesFile() { @@ -36,37 +40,52 @@ public void NewDirectory_GeneratesNonEmptyId_AndWritesFile() } [Test] - public void ExistingFile_ReturnsPreviousId_WithoutGeneratingNew() + public void ExistingFile_NewFormat_ReturnsPreviousAnonId() { - // 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 expectedAnonId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + var expectedDeviceId = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; var dir = AudiencePaths.AudienceDir(_testDir); Directory.CreateDirectory(dir); - File.WriteAllText(AudiencePaths.IdentityFile(_testDir), expectedId); + File.WriteAllText(AudiencePaths.IdentityFile(_testDir), + $"{{\"anonymousId\":\"{expectedAnonId}\",\"deviceId\":\"{expectedDeviceId}\"}}"); var result = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); - Assert.AreEqual(expectedId, result); + Assert.AreEqual(expectedAnonId, result); } [Test] - public void SecondCall_ReturnsSameId() + public void ExistingFile_LegacyPlainString_MigratesAnonId_AndGeneratesDeviceId() { - var id1 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); - var id2 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + var legacyAnonId = "pre-existing-id-from-last-launch"; + var dir = AudiencePaths.AudienceDir(_testDir); + Directory.CreateDirectory(dir); + File.WriteAllText(AudiencePaths.IdentityFile(_testDir), legacyAnonId); - Assert.AreEqual(id1, id2); + var result = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + Assert.AreEqual(legacyAnonId, result, "legacy anon_id should be preserved after migration"); + + // File must be rewritten as JSON; verify by re-reading via a cold cache. + Identity.ClearCache(); + var anonIdAfterReload = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + var deviceIdAfterReload = Identity.GetOrCreateDeviceId(_testDir, ConsentLevel.Anonymous); + + Assert.AreEqual(legacyAnonId, anonIdAfterReload, "migrated anon_id must survive a cache-clear + reload"); + Assert.IsNotNull(deviceIdAfterReload, "device_id should be generated during migration and persist"); + Assert.IsNotEmpty(deviceIdAfterReload); + + // File must now be valid JSON, not a plain string. + var raw = File.ReadAllText(AudiencePaths.IdentityFile(_testDir)).Trim(); + Assert.IsTrue(raw.StartsWith("{"), "identity file must be rewritten to JSON format after migration"); } [Test] - public void Reset_NextCallReturnsDifferentId() + public void SecondCall_ReturnsSameAnonId() { var id1 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); - Identity.Reset(_testDir); var id2 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); - Assert.IsNotNull(id2); - Assert.AreNotEqual(id1, id2); + Assert.AreEqual(id1, id2); } [Test] @@ -80,6 +99,132 @@ public void ConsentNone_ReturnsNull_AndNoFileWritten() Assert.IsFalse(File.Exists(filePath), "identity file must not be written when consent is None"); } + // ----------------------------------------------------------------- + // GetOrCreateDeviceId + // ----------------------------------------------------------------- + + [Test] + public void GetOrCreateDeviceId_Anonymous_ReturnsNonEmptyId() + { + var deviceId = Identity.GetOrCreateDeviceId(_testDir, ConsentLevel.Anonymous); + + Assert.IsNotNull(deviceId); + Assert.IsNotEmpty(deviceId); + } + + [Test] + public void GetOrCreateDeviceId_None_ReturnsNull_AndNoFileWritten() + { + var deviceId = Identity.GetOrCreateDeviceId(_testDir, ConsentLevel.None); + + Assert.IsNull(deviceId); + Assert.IsFalse(File.Exists(AudiencePaths.IdentityFile(_testDir))); + } + + [Test] + public void GetOrCreateDeviceId_SameAcrossMultipleCalls() + { + var id1 = Identity.GetOrCreateDeviceId(_testDir, ConsentLevel.Anonymous); + var id2 = Identity.GetOrCreateDeviceId(_testDir, ConsentLevel.Anonymous); + + Assert.AreEqual(id1, id2); + } + + [Test] + public void GetOrCreateDeviceId_PersistedAcrossSimulatedRestart() + { + var deviceId1 = Identity.GetOrCreateDeviceId(_testDir, ConsentLevel.Anonymous); + Identity.ClearCache(); + var deviceId2 = Identity.GetOrCreateDeviceId(_testDir, ConsentLevel.Anonymous); + + Assert.AreEqual(deviceId1, deviceId2, "device_id must survive a simulated restart (cache clear)"); + } + + [Test] + public void GetOrCreateDeviceId_DifferentFromAnonId() + { + var anonId = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + var deviceId = Identity.GetOrCreateDeviceId(_testDir, ConsentLevel.Anonymous); + + Assert.AreNotEqual(anonId, deviceId, "device_id and anon_id must be distinct UUIDs"); + } + + // ----------------------------------------------------------------- + // RotateAnonymousId (logout) + // ----------------------------------------------------------------- + + [Test] + public void RotateAnonymousId_ChangesAnonId() + { + var anonId1 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + Identity.RotateAnonymousId(_testDir); + var anonId2 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + + Assert.IsNotNull(anonId2); + Assert.AreNotEqual(anonId1, anonId2, "anon_id must rotate on logout"); + } + + [Test] + public void RotateAnonymousId_PreservesDeviceId() + { + var deviceId1 = Identity.GetOrCreateDeviceId(_testDir, ConsentLevel.Anonymous); + Identity.RotateAnonymousId(_testDir); + var deviceId2 = Identity.GetOrCreateDeviceId(_testDir, ConsentLevel.Anonymous); + + Assert.AreEqual(deviceId1, deviceId2, "device_id must survive logout (RotateAnonymousId)"); + } + + [Test] + public void RotateAnonymousId_DeviceIdPersistedAcrossRestart() + { + var deviceId1 = Identity.GetOrCreateDeviceId(_testDir, ConsentLevel.Anonymous); + Identity.RotateAnonymousId(_testDir); + Identity.ClearCache(); + var deviceId2 = Identity.GetOrCreateDeviceId(_testDir, ConsentLevel.Anonymous); + + Assert.AreEqual(deviceId1, deviceId2, "device_id must persist through logout + simulated restart"); + } + + // ----------------------------------------------------------------- + // Reset (opt-out, full wipe) + // ----------------------------------------------------------------- + + [Test] + public void Reset_WipesAnonId() + { + var id1 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + Identity.Reset(_testDir); + var id2 = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + + Assert.IsNotNull(id2); + Assert.AreNotEqual(id1, id2, "Reset must wipe anon_id"); + } + + [Test] + public void Reset_WipesDeviceId() + { + var deviceId1 = Identity.GetOrCreateDeviceId(_testDir, ConsentLevel.Anonymous); + Identity.Reset(_testDir); + var deviceId2 = Identity.GetOrCreateDeviceId(_testDir, ConsentLevel.Anonymous); + + Assert.AreNotEqual(deviceId1, deviceId2, "Reset (opt-out) must wipe device_id"); + } + + [Test] + public void Reset_DeletesFile() + { + Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); + Assert.IsTrue(File.Exists(AudiencePaths.IdentityFile(_testDir))); + + Identity.Reset(_testDir); + Assert.IsFalse(File.Exists(AudiencePaths.IdentityFile(_testDir)), + "Reset must delete the identity file"); + } + + // ----------------------------------------------------------------- + // Get (non-creating read for DeleteData) + // ----------------------------------------------------------------- + [Test] public void Get_NoExistingFile_ReturnsNull_AndDoesNotCreate() { @@ -91,7 +236,19 @@ public void Get_NoExistingFile_ReturnsNull_AndDoesNotCreate() } [Test] - public void Get_ExistingFile_ReturnsPersistedId() + public void Get_ExistingNewFormatFile_ReturnsAnonId() + { + var expectedAnonId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + var dir = AudiencePaths.AudienceDir(_testDir); + Directory.CreateDirectory(dir); + File.WriteAllText(AudiencePaths.IdentityFile(_testDir), + $"{{\"anonymousId\":\"{expectedAnonId}\",\"deviceId\":\"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb\"}}"); + + Assert.AreEqual(expectedAnonId, Identity.Get(_testDir)); + } + + [Test] + public void Get_ExistingLegacyFile_ReturnsAnonId() { var expectedId = "pre-existing-id"; var dir = AudiencePaths.AudienceDir(_testDir); diff --git a/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs b/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs index ef634706..e6bcf40f 100644 --- a/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs @@ -9,11 +9,13 @@ namespace Immutable.Audience.Tests public class MessageBuilderTests { private const string PackageVersion = "1.2.3"; + private const string AnonId = "anon-1"; + private const string DeviceId = "device-1"; [Test] public void Track_RequiredFieldsPresent() { - var result = MessageBuilder.Track("level_complete", "anon-1", null, PackageVersion); + var result = MessageBuilder.Track("level_complete", AnonId, null, null, PackageVersion); Assert.AreEqual("track", result["type"]); Assert.IsTrue(result.ContainsKey("messageId")); @@ -28,7 +30,7 @@ public void Track_EventNameLongerThan256Chars_TruncatedTo256() { var longName = new string('x', 300); - var result = MessageBuilder.Track(longName, null, null, PackageVersion); + var result = MessageBuilder.Track(longName, null, null, null, PackageVersion); Assert.AreEqual(256, ((string)result["eventName"]).Length); } @@ -36,7 +38,7 @@ public void Track_EventNameLongerThan256Chars_TruncatedTo256() [Test] public void Track_NullUserId_NotPresentInDict() { - var result = MessageBuilder.Track("evt", "anon-1", null, PackageVersion); + var result = MessageBuilder.Track("evt", AnonId, null, null, PackageVersion); Assert.IsFalse(result.ContainsKey("userId")); } @@ -44,16 +46,33 @@ public void Track_NullUserId_NotPresentInDict() [Test] public void Track_NonNullUserId_PresentInDict() { - var result = MessageBuilder.Track("evt", "anon-1", "user-99", PackageVersion); + var result = MessageBuilder.Track("evt", AnonId, "user-99", null, PackageVersion); Assert.IsTrue(result.ContainsKey("userId")); Assert.AreEqual("user-99", result["userId"]); } + [Test] + public void Track_DeviceId_PresentWhenProvided() + { + var result = MessageBuilder.Track("evt", AnonId, null, DeviceId, PackageVersion); + + Assert.IsTrue(result.ContainsKey("deviceId")); + Assert.AreEqual(DeviceId, result["deviceId"]); + } + + [Test] + public void Track_DeviceId_AbsentWhenNull() + { + var result = MessageBuilder.Track("evt", AnonId, null, null, PackageVersion); + + Assert.IsFalse(result.ContainsKey("deviceId")); + } + [Test] public void Identify_TypeAndIdentityFieldsPresent() { - var result = MessageBuilder.Identify("anon-42", "user-42", "steam", PackageVersion); + var result = MessageBuilder.Identify("anon-42", "user-42", null, "steam", PackageVersion); Assert.AreEqual("identify", result["type"]); Assert.AreEqual("anon-42", result["anonymousId"]); @@ -61,10 +80,19 @@ public void Identify_TypeAndIdentityFieldsPresent() Assert.AreEqual("steam", result["identityType"]); } + [Test] + public void Identify_DeviceId_PresentWhenProvided() + { + var result = MessageBuilder.Identify("anon-42", "user-42", DeviceId, "steam", PackageVersion); + + Assert.IsTrue(result.ContainsKey("deviceId")); + Assert.AreEqual(DeviceId, result["deviceId"]); + } + [Test] public void Alias_AllFourFieldsPresent() { - var result = MessageBuilder.Alias("from-id", "email", "to-id", "steam", PackageVersion); + var result = MessageBuilder.Alias("from-id", "email", "to-id", "steam", null, PackageVersion); Assert.AreEqual("alias", result["type"]); Assert.AreEqual("from-id", result["fromId"]); @@ -73,12 +101,21 @@ public void Alias_AllFourFieldsPresent() Assert.AreEqual("steam", result["toType"]); } + [Test] + public void Alias_DeviceId_PresentWhenProvided() + { + var result = MessageBuilder.Alias("from-id", "email", "to-id", "steam", DeviceId, PackageVersion); + + Assert.IsTrue(result.ContainsKey("deviceId")); + Assert.AreEqual(DeviceId, result["deviceId"]); + } + [Test] public void AllMessages_ContextContainsLibraryAndLibraryVersion() { - var track = MessageBuilder.Track("evt", null, null, PackageVersion); - var identify = MessageBuilder.Identify(null, "u1", "steam", PackageVersion); - var alias = MessageBuilder.Alias("f", "t1", "t", "t2", PackageVersion); + var track = MessageBuilder.Track("evt", null, null, null, PackageVersion); + var identify = MessageBuilder.Identify(null, "u1", null, "steam", PackageVersion); + var alias = MessageBuilder.Alias("f", "t1", "t", "t2", null, PackageVersion); foreach (var msg in new[] { track, identify, alias }) { @@ -91,9 +128,9 @@ public void AllMessages_ContextContainsLibraryAndLibraryVersion() [Test] public void AllMessages_SurfaceIsUnity() { - var track = MessageBuilder.Track("evt", null, null, PackageVersion); - var identify = MessageBuilder.Identify(null, "u1", "steam", PackageVersion); - var alias = MessageBuilder.Alias("f", "t1", "t", "t2", PackageVersion); + var track = MessageBuilder.Track("evt", null, null, null, PackageVersion); + var identify = MessageBuilder.Identify(null, "u1", null, "steam", PackageVersion); + var alias = MessageBuilder.Alias("f", "t1", "t", "t2", null, PackageVersion); Assert.AreEqual("unity", track["surface"]); Assert.AreEqual("unity", identify["surface"]); @@ -117,7 +154,7 @@ public void Track_MessageId_IsUniquePerCall() // Backend deduplicates on messageId; collisions silently drop events. var ids = new HashSet(); for (var i = 0; i < 1000; i++) - ids.Add((string)MessageBuilder.Track("evt", null, null, PackageVersion)["messageId"]); + ids.Add((string)MessageBuilder.Track("evt", null, null, null, PackageVersion)["messageId"]); Assert.AreEqual(1000, ids.Count); } @@ -159,7 +196,7 @@ public void AllMessages_Context_LibraryAndLibraryVersionAreNonEmptyStrings() [Test] public void Track_TestModeTrue_IncludesTestFlag() { - var result = MessageBuilder.Track("evt", null, null, PackageVersion, testMode: true); + var result = MessageBuilder.Track("evt", null, null, null, PackageVersion, testMode: true); Assert.IsTrue(result.ContainsKey("test"), "test field must be present when testMode is true"); Assert.AreEqual(true, result["test"]); } @@ -167,16 +204,16 @@ public void Track_TestModeTrue_IncludesTestFlag() [Test] public void Track_TestModeFalse_ExcludesTestFlag() { - var result = MessageBuilder.Track("evt", null, null, PackageVersion, testMode: false); + var result = MessageBuilder.Track("evt", null, null, null, PackageVersion, testMode: false); Assert.IsFalse(result.ContainsKey("test"), "test field must not be present when testMode is false"); } [Test] public void AllMessages_TestModeTrue_AllIncludeTestFlag() { - var track = MessageBuilder.Track("evt", null, null, PackageVersion, testMode: true); - var identify = MessageBuilder.Identify(null, "u1", "steam", PackageVersion, testMode: true); - var alias = MessageBuilder.Alias("f", "t1", "t", "t2", PackageVersion, testMode: true); + var track = MessageBuilder.Track("evt", null, null, null, PackageVersion, testMode: true); + var identify = MessageBuilder.Identify(null, "u1", null, "steam", PackageVersion, testMode: true); + var alias = MessageBuilder.Alias("f", "t1", "t", "t2", null, PackageVersion, testMode: true); foreach (var msg in new[] { track, identify, alias }) { @@ -187,9 +224,9 @@ public void AllMessages_TestModeTrue_AllIncludeTestFlag() private static IEnumerable> EveryMessageType() { - yield return MessageBuilder.Track("evt", null, null, PackageVersion); - yield return MessageBuilder.Identify(null, "u1", "steam", PackageVersion); - yield return MessageBuilder.Alias("f", "t1", "t", "t2", PackageVersion); + yield return MessageBuilder.Track("evt", null, null, null, PackageVersion); + yield return MessageBuilder.Identify(null, "u1", null, "steam", PackageVersion); + yield return MessageBuilder.Alias("f", "t1", "t", "t2", null, PackageVersion); } } } diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index e5693e24..2517b1e1 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -142,6 +142,92 @@ public void AnonymousId_ConsentAnonymous_ReturnsPersistedId() "AnonymousId should return the persisted id once tracking has created one"); } + // ----------------------------------------------------------------- + // DeviceId + // ----------------------------------------------------------------- + + [Test] + public void DeviceId_ConsentNone_ReturnsNull() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.None)); + Assert.IsNull(ImmutableAudience.DeviceId); + } + + [Test] + public void DeviceId_ConsentAnonymous_ReturnsNonEmptyId() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + ImmutableAudience.Track("warmup"); + + Assert.IsFalse(string.IsNullOrEmpty(ImmutableAudience.DeviceId)); + } + + [Test] + public void DeviceId_DifferentFromAnonymousId() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + ImmutableAudience.Track("warmup"); + + Assert.AreNotEqual(ImmutableAudience.AnonymousId, ImmutableAudience.DeviceId); + } + + [Test] + public void DeviceId_SurvivesReset() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + ImmutableAudience.Track("warmup"); + + var deviceIdBefore = ImmutableAudience.DeviceId; + ImmutableAudience.Reset(); + + Assert.AreEqual(deviceIdBefore, ImmutableAudience.DeviceId, + "device_id must survive logout (Reset)"); + } + + [Test] + public void DeviceId_WipedBySetConsentNone() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + ImmutableAudience.Track("warmup"); + + var deviceIdBefore = ImmutableAudience.DeviceId; + ImmutableAudience.SetConsent(ConsentLevel.None); + ImmutableAudience.SetConsent(ConsentLevel.Anonymous); + ImmutableAudience.Track("post-optin"); + + Assert.AreNotEqual(deviceIdBefore, ImmutableAudience.DeviceId, + "device_id must be regenerated after opt-out then opt-back-in"); + } + + [Test] + public void DeviceId_PresentOnTrackEvents() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + ImmutableAudience.Track("test_event"); + ImmutableAudience.FlushQueueToDiskForTesting(); + + var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); + var blobs = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + + Assert.IsTrue(blobs.Any(b => b.Contains("\"deviceId\":")), + "track events must include deviceId at Anonymous+ consent"); + } + + [Test] + public void DeviceId_StableAcrossAnonIdRotation() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + ImmutableAudience.Track("warmup"); + + var deviceIdBefore = ImmutableAudience.DeviceId; + var anonIdBefore = ImmutableAudience.AnonymousId; + + ImmutableAudience.Reset(); + + Assert.AreNotEqual(anonIdBefore, ImmutableAudience.AnonymousId, "anon_id must rotate on Reset"); + Assert.AreEqual(deviceIdBefore, ImmutableAudience.DeviceId, "device_id must be stable across Reset"); + } + [Test] public void SessionId_MirrorsSessionLifecycle() {