Skip to content

Commit 303280e

Browse files
authored
Merge pull request #801 from immutable/feat/sdk-538-device-id
feat(audience): add persistent device_id that survives logout
2 parents 0abb5b0 + d7c1b6f commit 303280e

8 files changed

Lines changed: 532 additions & 111 deletions

File tree

src/Packages/Audience/Runtime/Core/Constants.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ internal static class MessageFields
5252
{
5353
internal const string Type = "type";
5454
internal const string UserId = "userId";
55+
internal const string DeviceId = "deviceId";
5556
}
5657

5758
/// <summary>

src/Packages/Audience/Runtime/Core/Identity.cs

Lines changed: 161 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -5,132 +5,227 @@
55

66
namespace Immutable.Audience
77
{
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).
1011
//
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.
1414
internal sealed class Identity
1515
{
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;
1818
private static readonly object _sync = new object();
1919

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.
2221
internal static string? Get(string persistentDataPath)
2322
{
24-
if (_cachedId != null) return _cachedId;
23+
if (_cachedAnonId != null) return _cachedAnonId;
2524

2625
lock (_sync)
2726
{
28-
if (_cachedId != null) return _cachedId;
27+
if (_cachedAnonId != null) return _cachedAnonId;
2928

3029
try
3130
{
3231
var filePath = AudiencePaths.IdentityFile(persistentDataPath);
3332
if (!File.Exists(filePath)) return null;
3433

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;
4538
}
39+
catch (IOException) { return null; }
40+
catch (UnauthorizedAccessException) { return null; }
4641
}
4742
}
4843

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
5146
// persistentDataPath re-reads the file from the new location.
5247
internal static void ClearCache()
5348
{
5449
lock (_sync)
5550
{
56-
_cachedId = null;
51+
_cachedAnonId = null;
52+
_cachedDeviceId = null;
5753
}
5854
}
5955

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.
6358
internal static string? GetOrCreate(string persistentDataPath, ConsentLevel consent)
6459
{
65-
// No ID until the player grants at least anonymous consent.
66-
if (!consent.CanTrack())
67-
return null;
60+
if (!consent.CanTrack()) return null;
6861

69-
// Fast path: already loaded this session, no lock needed.
70-
if (_cachedId != null)
71-
return _cachedId;
62+
if (_cachedAnonId != null) return _cachedAnonId;
7263

73-
// Slow path: first call or after Reset(). Only one thread does the work.
7464
lock (_sync)
7565
{
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;
7967

80-
var dir = AudiencePaths.AudienceDir(persistentDataPath);
81-
Directory.CreateDirectory(dir); // no-op if already exists
68+
LoadOrGenerate(persistentDataPath);
69+
return _cachedAnonId;
70+
}
71+
}
8272

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;
8477

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)
8796
{
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) { }
90106
}
91107

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;
97109

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();
98120
try
99121
{
100-
File.Move(tmpPath, filePath);
122+
WriteFile(AudiencePaths.IdentityFile(persistentDataPath), newAnonId, deviceId);
123+
_cachedAnonId = newAnonId;
101124
}
102-
catch (IOException)
125+
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
103126
{
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));
108128
}
109-
110-
_cachedId = newId;
111-
return _cachedId;
112129
}
113130
}
114131

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).
118133
internal static void Reset(string persistentDataPath)
119134
{
120135
lock (_sync)
121136
{
122-
_cachedId = null;
137+
_cachedAnonId = null;
138+
_cachedDeviceId = null;
123139

124140
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+
126177
{
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;
128183
}
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
130200
{
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;
132207
}
208+
catch (Exception) { }
133209
}
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);
134229
}
135230
}
136231
}

src/Packages/Audience/Runtime/Events/MessageBuilder.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ internal static Dictionary<string, object> Track(
1111
string eventName,
1212
string? anonymousId,
1313
string? userId,
14+
string? deviceId,
1415
string packageVersion,
1516
Dictionary<string, object>? properties = null,
1617
bool testMode = false)
@@ -24,6 +25,9 @@ internal static Dictionary<string, object> Track(
2425
if (!string.IsNullOrEmpty(userId))
2526
msg[MessageFields.UserId] = Truncate(userId, Constants.MaxFieldLength);
2627

28+
if (!string.IsNullOrEmpty(deviceId))
29+
msg[MessageFields.DeviceId] = Truncate(deviceId, Constants.MaxFieldLength);
30+
2731
if (properties != null && properties.Count > 0)
2832
{
2933
TruncateStringValues(properties);
@@ -36,6 +40,7 @@ internal static Dictionary<string, object> Track(
3640
internal static Dictionary<string, object> Identify(
3741
string? anonymousId,
3842
string? userId,
43+
string? deviceId,
3944
string identityType,
4045
string packageVersion,
4146
Dictionary<string, object>? traits = null,
@@ -49,6 +54,9 @@ internal static Dictionary<string, object> Identify(
4954
if (!string.IsNullOrEmpty(userId))
5055
msg[MessageFields.UserId] = Truncate(userId, Constants.MaxFieldLength);
5156

57+
if (!string.IsNullOrEmpty(deviceId))
58+
msg[MessageFields.DeviceId] = Truncate(deviceId, Constants.MaxFieldLength);
59+
5260
msg["identityType"] = Truncate(identityType, Constants.MaxFieldLength);
5361

5462
if (traits != null && traits.Count > 0)
@@ -65,6 +73,7 @@ internal static Dictionary<string, object> Alias(
6573
string fromType,
6674
string toId,
6775
string toType,
76+
string? deviceId,
6877
string packageVersion,
6978
bool testMode = false)
7079
{
@@ -73,6 +82,10 @@ internal static Dictionary<string, object> Alias(
7382
msg["fromType"] = Truncate(fromType, Constants.MaxFieldLength);
7483
msg["toId"] = Truncate(toId, Constants.MaxFieldLength);
7584
msg["toType"] = Truncate(toType, Constants.MaxFieldLength);
85+
86+
if (!string.IsNullOrEmpty(deviceId))
87+
msg[MessageFields.DeviceId] = Truncate(deviceId, Constants.MaxFieldLength);
88+
7689
return msg;
7790
}
7891

0 commit comments

Comments
 (0)