|
5 | 5 |
|
6 | 6 | namespace Immutable.Audience |
7 | 7 | { |
8 | | - // Manages the anonymous ID that identifies a device across sessions. |
9 | | - // The ID is a UUID generated once, written to disk, and reused on every subsequent launch. |
| 8 | + // Manages the anonymous ID and device ID for this device. |
| 9 | + // Both are UUIDs persisted to {"anonymousId":"<uuid>","deviceId":"<uuid>"}. |
| 10 | + // deviceId survives RotateAnonymousId (logout); both are wiped by Reset (opt-out). |
10 | 11 | // |
11 | | - // Note: _cachedId is a static field. In the Unity Editor with domain reload disabled, |
12 | | - // it persists across play sessions. ImmutableAudience.Init() is responsible for calling |
13 | | - // Reset() at startup to ensure a clean state in that scenario. |
| 12 | + // Static caches persist across play sessions in the Unity Editor with domain reload |
| 13 | + // disabled. ImmutableAudience.Init() calls ClearCache() via ResetState() to handle that. |
14 | 14 | internal sealed class Identity |
15 | 15 | { |
16 | | - // In-memory cache. Volatile so background threads always see the latest write. |
17 | | - private static volatile string? _cachedId; |
| 16 | + private static volatile string? _cachedAnonId; |
| 17 | + private static volatile string? _cachedDeviceId; |
18 | 18 | private static readonly object _sync = new object(); |
19 | 19 |
|
20 | | - // Returns the existing anonymous ID, or null if none exists. |
21 | | - // Unlike GetOrCreate, never generates or persists a new one. |
| 20 | + // Returns the existing anonymous ID without creating one. Used by DeleteData. |
22 | 21 | internal static string? Get(string persistentDataPath) |
23 | 22 | { |
24 | | - if (_cachedId != null) return _cachedId; |
| 23 | + if (_cachedAnonId != null) return _cachedAnonId; |
25 | 24 |
|
26 | 25 | lock (_sync) |
27 | 26 | { |
28 | | - if (_cachedId != null) return _cachedId; |
| 27 | + if (_cachedAnonId != null) return _cachedAnonId; |
29 | 28 |
|
30 | 29 | try |
31 | 30 | { |
32 | 31 | var filePath = AudiencePaths.IdentityFile(persistentDataPath); |
33 | 32 | if (!File.Exists(filePath)) return null; |
34 | 33 |
|
35 | | - _cachedId = File.ReadAllText(filePath).Trim(); |
36 | | - return _cachedId; |
37 | | - } |
38 | | - catch (IOException) |
39 | | - { |
40 | | - return null; |
41 | | - } |
42 | | - catch (UnauthorizedAccessException) |
43 | | - { |
44 | | - return null; |
| 34 | + var content = File.ReadAllText(filePath).Trim(); |
| 35 | + ParseFile(content, out var anonId, out _); |
| 36 | + _cachedAnonId = anonId; |
| 37 | + return _cachedAnonId; |
45 | 38 | } |
| 39 | + catch (IOException) { return null; } |
| 40 | + catch (UnauthorizedAccessException) { return null; } |
46 | 41 | } |
47 | 42 | } |
48 | 43 |
|
49 | | - // Drops the in-memory cache without touching disk. Called on |
50 | | - // Shutdown/ResetState so a subsequent Init with a different |
| 44 | + // Clears both in-memory caches without touching disk. |
| 45 | + // Called on Shutdown/ResetState so a subsequent Init with a different |
51 | 46 | // persistentDataPath re-reads the file from the new location. |
52 | 47 | internal static void ClearCache() |
53 | 48 | { |
54 | 49 | lock (_sync) |
55 | 50 | { |
56 | | - _cachedId = null; |
| 51 | + _cachedAnonId = null; |
| 52 | + _cachedDeviceId = null; |
57 | 53 | } |
58 | 54 | } |
59 | 55 |
|
60 | | - // Returns the anonymous ID, generating and persisting it on first call. |
61 | | - // Returns null without touching disk when consent is None. |
62 | | - // Safe to call from any thread after ImmutableAudience.Init() has run on the main thread. |
| 56 | + // Returns the anonymous ID, generating and persisting both IDs on first call. |
| 57 | + // Returns null when consent is None. |
63 | 58 | internal static string? GetOrCreate(string persistentDataPath, ConsentLevel consent) |
64 | 59 | { |
65 | | - // No ID until the player grants at least anonymous consent. |
66 | | - if (!consent.CanTrack()) |
67 | | - return null; |
| 60 | + if (!consent.CanTrack()) return null; |
68 | 61 |
|
69 | | - // Fast path: already loaded this session, no lock needed. |
70 | | - if (_cachedId != null) |
71 | | - return _cachedId; |
| 62 | + if (_cachedAnonId != null) return _cachedAnonId; |
72 | 63 |
|
73 | | - // Slow path: first call or after Reset(). Only one thread does the work. |
74 | 64 | lock (_sync) |
75 | 65 | { |
76 | | - // Re-check after acquiring the lock in case another thread beat us here. |
77 | | - if (_cachedId != null) |
78 | | - return _cachedId; |
| 66 | + if (_cachedAnonId != null) return _cachedAnonId; |
79 | 67 |
|
80 | | - var dir = AudiencePaths.AudienceDir(persistentDataPath); |
81 | | - Directory.CreateDirectory(dir); // no-op if already exists |
| 68 | + LoadOrGenerate(persistentDataPath); |
| 69 | + return _cachedAnonId; |
| 70 | + } |
| 71 | + } |
82 | 72 |
|
83 | | - var filePath = AudiencePaths.IdentityFile(persistentDataPath); |
| 73 | + // Returns the device ID at Anonymous+ consent, null at None. |
| 74 | + internal static string? GetOrCreateDeviceId(string persistentDataPath, ConsentLevel consent) |
| 75 | + { |
| 76 | + if (!consent.CanTrack()) return null; |
84 | 77 |
|
85 | | - // Returning player: read the ID we wrote on a previous launch. |
86 | | - if (File.Exists(filePath)) |
| 78 | + if (_cachedDeviceId != null) return _cachedDeviceId; |
| 79 | + |
| 80 | + lock (_sync) |
| 81 | + { |
| 82 | + if (_cachedDeviceId != null) return _cachedDeviceId; |
| 83 | + |
| 84 | + LoadOrGenerate(persistentDataPath); |
| 85 | + return _cachedDeviceId; |
| 86 | + } |
| 87 | + } |
| 88 | + |
| 89 | + // Logout: rotates anon_id and rewrites the file, preserving device_id. |
| 90 | + internal static void RotateAnonymousId(string persistentDataPath) |
| 91 | + { |
| 92 | + lock (_sync) |
| 93 | + { |
| 94 | + var deviceId = _cachedDeviceId; |
| 95 | + if (deviceId == null) |
87 | 96 | { |
88 | | - _cachedId = File.ReadAllText(filePath).Trim(); |
89 | | - return _cachedId; |
| 97 | + try |
| 98 | + { |
| 99 | + var fp = AudiencePaths.IdentityFile(persistentDataPath); |
| 100 | + if (File.Exists(fp)) |
| 101 | + { |
| 102 | + ParseFile(File.ReadAllText(fp).Trim(), out _, out deviceId); |
| 103 | + } |
| 104 | + } |
| 105 | + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { } |
90 | 106 | } |
91 | 107 |
|
92 | | - // New install: generate a UUID and persist it atomically. |
93 | | - // Write to a .tmp file first so a crash mid-write leaves no corrupt file. |
94 | | - var newId = Guid.NewGuid().ToString(); |
95 | | - var tmpPath = filePath + ".tmp"; |
96 | | - File.WriteAllText(tmpPath, newId); |
| 108 | + _cachedAnonId = null; |
97 | 109 |
|
| 110 | + if (string.IsNullOrEmpty(deviceId)) |
| 111 | + { |
| 112 | + // Nothing to preserve: delete so the next GetOrCreate regenerates both fresh. |
| 113 | + try { File.Delete(AudiencePaths.IdentityFile(persistentDataPath)); } |
| 114 | + catch (Exception ex) when (ex is FileNotFoundException || ex is DirectoryNotFoundException) { } |
| 115 | + return; |
| 116 | + } |
| 117 | + |
| 118 | + // Rewrite with a new anon_id, same device_id. |
| 119 | + var newAnonId = Guid.NewGuid().ToString(); |
98 | 120 | try |
99 | 121 | { |
100 | | - File.Move(tmpPath, filePath); |
| 122 | + WriteFile(AudiencePaths.IdentityFile(persistentDataPath), newAnonId, deviceId); |
| 123 | + _cachedAnonId = newAnonId; |
101 | 124 | } |
102 | | - catch (IOException) |
| 125 | + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) |
103 | 126 | { |
104 | | - // Unexpected: file appeared between our Exists check and Move (shouldn't happen in practice). |
105 | | - // Delete and retry to ensure a clean state. |
106 | | - File.Delete(filePath); |
107 | | - File.Move(tmpPath, filePath); |
| 127 | + Log.Warn(AudienceLogs.IdentityRotateFailed(ex)); |
108 | 128 | } |
109 | | - |
110 | | - _cachedId = newId; |
111 | | - return _cachedId; |
112 | 129 | } |
113 | 130 | } |
114 | 131 |
|
115 | | - // Clears the cached ID and deletes the persisted file. |
116 | | - // Called on logout or when consent is downgraded to None. |
117 | | - // The next GetOrCreate call will generate a fresh ID. |
| 132 | + // Full wipe: clears both IDs and deletes the file. Called on SetConsent(None). |
118 | 133 | internal static void Reset(string persistentDataPath) |
119 | 134 | { |
120 | 135 | lock (_sync) |
121 | 136 | { |
122 | | - _cachedId = null; |
| 137 | + _cachedAnonId = null; |
| 138 | + _cachedDeviceId = null; |
123 | 139 |
|
124 | 140 | var filePath = AudiencePaths.IdentityFile(persistentDataPath); |
125 | | - try |
| 141 | + try { File.Delete(filePath); } |
| 142 | + catch (Exception ex) when (ex is FileNotFoundException || ex is DirectoryNotFoundException) { } |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + // Slow path: read from disk or generate fresh IDs. Must be called under _sync. |
| 147 | + private static void LoadOrGenerate(string persistentDataPath) |
| 148 | + { |
| 149 | + try |
| 150 | + { |
| 151 | + var dir = AudiencePaths.AudienceDir(persistentDataPath); |
| 152 | + Directory.CreateDirectory(dir); |
| 153 | + |
| 154 | + var filePath = AudiencePaths.IdentityFile(persistentDataPath); |
| 155 | + |
| 156 | + if (File.Exists(filePath)) |
| 157 | + { |
| 158 | + var content = File.ReadAllText(filePath).Trim(); |
| 159 | + ParseFile(content, out var existingAnonId, out var existingDeviceId); |
| 160 | + |
| 161 | + if (!string.IsNullOrEmpty(existingAnonId) && !string.IsNullOrEmpty(existingDeviceId)) |
| 162 | + { |
| 163 | + _cachedAnonId = existingAnonId; |
| 164 | + _cachedDeviceId = existingDeviceId; |
| 165 | + return; |
| 166 | + } |
| 167 | + |
| 168 | + // Partial or old plain-string format: keep anon_id, generate device_id, migrate file. |
| 169 | + var anonId = string.IsNullOrEmpty(existingAnonId) ? Guid.NewGuid().ToString() : existingAnonId; |
| 170 | + var deviceId = Guid.NewGuid().ToString(); |
| 171 | + WriteFile(filePath, anonId, deviceId); |
| 172 | + _cachedAnonId = anonId; |
| 173 | + _cachedDeviceId = deviceId; |
| 174 | + return; |
| 175 | + } |
| 176 | + |
126 | 177 | { |
127 | | - File.Delete(filePath); |
| 178 | + var anonId = Guid.NewGuid().ToString(); |
| 179 | + var deviceId = Guid.NewGuid().ToString(); |
| 180 | + WriteFile(filePath, anonId, deviceId); |
| 181 | + _cachedAnonId = anonId; |
| 182 | + _cachedDeviceId = deviceId; |
128 | 183 | } |
129 | | - catch (Exception e) when (e is FileNotFoundException || e is DirectoryNotFoundException) |
| 184 | + } |
| 185 | + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) |
| 186 | + { |
| 187 | + Log.Warn(AudienceLogs.IdentityLoadOrGenerateFailed(ex)); |
| 188 | + } |
| 189 | + } |
| 190 | + |
| 191 | + // Handles two formats: JSON {"anonymousId":...,"deviceId":...} and the legacy plain UUID string. |
| 192 | + private static void ParseFile(string content, out string? anonId, out string? deviceId) |
| 193 | + { |
| 194 | + anonId = null; |
| 195 | + deviceId = null; |
| 196 | + |
| 197 | + if (content.StartsWith("{")) |
| 198 | + { |
| 199 | + try |
130 | 200 | { |
131 | | - // File was never written (e.g. consent was None). Nothing to do. |
| 201 | + var obj = JsonReader.DeserializeObject(content); |
| 202 | + obj.TryGetValue("anonymousId", out var a); |
| 203 | + obj.TryGetValue("deviceId", out var d); |
| 204 | + anonId = a as string; |
| 205 | + deviceId = d as string; |
| 206 | + return; |
132 | 207 | } |
| 208 | + catch (Exception) { } |
133 | 209 | } |
| 210 | + |
| 211 | + // Legacy plain-UUID format. |
| 212 | + if (!string.IsNullOrEmpty(content)) |
| 213 | + anonId = content; |
| 214 | + } |
| 215 | + |
| 216 | + private static void WriteFile(string filePath, string anonId, string deviceId) |
| 217 | + { |
| 218 | + var json = Json.Serialize(new System.Collections.Generic.Dictionary<string, object> |
| 219 | + { |
| 220 | + ["anonymousId"] = anonId, |
| 221 | + ["deviceId"] = deviceId, |
| 222 | + }); |
| 223 | + var tmpPath = filePath + ".tmp"; |
| 224 | + File.WriteAllText(tmpPath, json); |
| 225 | + if (File.Exists(filePath)) |
| 226 | + File.Replace(tmpPath, filePath, null); // atomic overwrite; no window where the file is absent |
| 227 | + else |
| 228 | + File.Move(tmpPath, filePath); |
134 | 229 | } |
135 | 230 | } |
136 | 231 | } |
0 commit comments