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/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
bin/
1 change: 1 addition & 0 deletions src/Packages/Audience/Runtime/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Immutable.Audience.Runtime.Tests")]
[assembly: InternalsVisibleTo("Immutable.Audience.Unity")]
10 changes: 7 additions & 3 deletions src/Packages/Audience/Runtime/Audience.Runtime.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@
<AssemblyName>Immutable.Audience.Runtime</AssemblyName>
</PropertyGroup>
<!--
Exclude modules that depend on Unity APIs (added in later PRs).
These compile fine in Unity but cannot be built with the .NET SDK alone.
Update this list as Unity-specific modules are added under Collection/ and Utility/MainThreadDispatcher.cs.
The Unity/ subtree belongs to the sibling Immutable.Audience.Unity asmdef.
It references UnityEngine, so it cannot build under the headless .NET SDK
used for Audience.Tests. Unity's own compiler builds it via
Runtime/Unity/com.immutable.audience.unity.asmdef.
-->
<ItemGroup>
<Compile Remove="Unity/**/*.cs" />
</ItemGroup>
</Project>
52 changes: 52 additions & 0 deletions src/Packages/Audience/Runtime/Core/ConsentStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#nullable enable

using System;
using System.IO;

namespace Immutable.Audience
{
internal static class ConsentStore
{
internal static void Save(string persistentDataPath, ConsentLevel level)
{
Directory.CreateDirectory(AudiencePaths.AudienceDir(persistentDataPath));

var filePath = AudiencePaths.ConsentFile(persistentDataPath);
var tmpPath = filePath + ".tmp";

File.WriteAllText(tmpPath, ((int)level).ToString());

try
{
File.Move(tmpPath, filePath);
}
catch (IOException)
{
File.Delete(filePath);
File.Move(tmpPath, filePath);
}
}

// Returns null on missing/malformed/unreadable file; caller falls back to config default.
internal static ConsentLevel? Load(string persistentDataPath)
{
try
{
var filePath = AudiencePaths.ConsentFile(persistentDataPath);
if (!File.Exists(filePath)) return null;

var text = File.ReadAllText(filePath).Trim();
if (int.TryParse(text, out var raw) && Enum.IsDefined(typeof(ConsentLevel), raw))
return (ConsentLevel)raw;
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}

return null;
}
}
}
28 changes: 27 additions & 1 deletion src/Packages/Audience/Runtime/Core/Constants.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#nullable enable

namespace Immutable.Audience
{
internal static class Constants
Expand All @@ -14,17 +16,41 @@ internal static class Constants
internal const int DefaultFlushSize = 20;
internal const int MaxBatchSize = 100;
internal const int StaleEventDays = 30;
internal const int MaxFieldLength = 256; // Backend schema limit.

internal const string LibraryName = "com.immutable.audience";
internal const string LibraryVersion = "0.1.0";
internal const string Surface = "unity";
internal const string ConsentSource = "UnitySDK";

internal static string BaseUrl(string publishableKey) =>
internal const string PublishableKeyHeader = "x-immutable-publishable-key";

internal static string MessagesUrl(string? publishableKey) => BaseUrl(publishableKey) + MessagesPath;
internal static string ConsentUrl(string? publishableKey) => BaseUrl(publishableKey) + ConsentPath;
internal static string DataUrl(string? publishableKey) => BaseUrl(publishableKey) + DataPath;

internal static string BaseUrl(string? publishableKey) =>
publishableKey != null && publishableKey.StartsWith(TestKeyPrefix)
? SandboxBaseUrl
: ProductionBaseUrl;
}

// Message type values written to (and read back from) the "type" field.
internal static class MessageTypes
{
internal const string Track = "track";
internal const string Identify = "identify";
internal const string Alias = "alias";
}

// Wire-format field names that cross module boundaries inside the SDK
// (read by one module, written by another).
internal static class MessageFields
{
internal const string Type = "type";
internal const string UserId = "userId";
}

// Common distribution platform values for AudienceConfig.DistributionPlatform.
public static class DistributionPlatforms
{
Expand Down
46 changes: 44 additions & 2 deletions src/Packages/Audience/Runtime/Core/Identity.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#nullable enable

using System;
using System.IO;

Expand All @@ -12,13 +14,53 @@ namespace Immutable.Audience
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? _cachedId;
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.
internal static string? Get(string persistentDataPath)
{
if (_cachedId != null) return _cachedId;

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

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

// Drops the in-memory cache 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;
}
}

// 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)
internal static string? GetOrCreate(string persistentDataPath, ConsentLevel consent)
{
// No ID until the player grants at least anonymous consent.
if (consent == ConsentLevel.None)
Expand Down
13 changes: 13 additions & 0 deletions src/Packages/Audience/Runtime/Events/IEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#nullable enable

using System.Collections.Generic;

namespace Immutable.Audience
{
// Typed event contract for ImmutableAudience.Track(IEvent).
public interface IEvent
{
string EventName { get; }
Dictionary<string, object> ToProperties();
}
}
65 changes: 42 additions & 23 deletions src/Packages/Audience/Runtime/Events/MessageBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#nullable enable

using System;
using System.Collections.Generic;

Expand All @@ -7,46 +9,52 @@ internal static class MessageBuilder
{
internal static Dictionary<string, object> Track(
string eventName,
string anonymousId,
string userId,
string? anonymousId,
string? userId,
string packageVersion,
Dictionary<string, object> properties = null)
Dictionary<string, object>? properties = null)
{
var msg = BuildBase("track", packageVersion);
msg["eventName"] = Truncate(eventName, 256);
var msg = BuildBase(MessageTypes.Track, packageVersion);
msg["eventName"] = Truncate(eventName, Constants.MaxFieldLength);

if (!string.IsNullOrEmpty(anonymousId))
msg["anonymousId"] = Truncate(anonymousId, 256);
msg["anonymousId"] = Truncate(anonymousId, Constants.MaxFieldLength);

if (!string.IsNullOrEmpty(userId))
msg["userId"] = Truncate(userId, 256);
msg[MessageFields.UserId] = Truncate(userId, Constants.MaxFieldLength);

if (properties != null && properties.Count > 0)
{
TruncateStringValues(properties);
msg["properties"] = properties;
}

return msg;
}

internal static Dictionary<string, object> Identify(
string anonymousId,
string userId,
string identityType,
string? anonymousId,
string? userId,
string? identityType,
string packageVersion,
Dictionary<string, object> traits = null)
Dictionary<string, object>? traits = null)
{
var msg = BuildBase("identify", packageVersion);
var msg = BuildBase(MessageTypes.Identify, packageVersion);

if (!string.IsNullOrEmpty(anonymousId))
msg["anonymousId"] = Truncate(anonymousId, 256);
msg["anonymousId"] = Truncate(anonymousId, Constants.MaxFieldLength);

if (!string.IsNullOrEmpty(userId))
msg["userId"] = Truncate(userId, 256);
msg[MessageFields.UserId] = Truncate(userId, Constants.MaxFieldLength);

if (!string.IsNullOrEmpty(identityType))
msg["identityType"] = Truncate(identityType, 256);
msg["identityType"] = Truncate(identityType, Constants.MaxFieldLength);

if (traits != null && traits.Count > 0)
{
TruncateStringValues(traits);
msg["traits"] = traits;
}

return msg;
}
Expand All @@ -58,35 +66,46 @@ internal static Dictionary<string, object> Alias(
string toType,
string packageVersion)
{
var msg = BuildBase("alias", packageVersion);
msg["fromId"] = Truncate(fromId, 256);
msg["fromType"] = Truncate(fromType, 256);
msg["toId"] = Truncate(toId, 256);
msg["toType"] = Truncate(toType, 256);
var msg = BuildBase(MessageTypes.Alias, packageVersion);
msg["fromId"] = Truncate(fromId, Constants.MaxFieldLength);
msg["fromType"] = Truncate(fromType, Constants.MaxFieldLength);
msg["toId"] = Truncate(toId, Constants.MaxFieldLength);
msg["toType"] = Truncate(toType, Constants.MaxFieldLength);
return msg;
}

private static Dictionary<string, object> BuildBase(string type, string packageVersion)
{
return new Dictionary<string, object>
{
["type"] = type,
[MessageFields.Type] = type,
["messageId"] = Guid.NewGuid().ToString(),
["eventTimestamp"] = DateTime.UtcNow.ToString("o"),
["context"] = new Dictionary<string, object>
{
["library"] = Constants.LibraryName,
["libraryVersion"] = Truncate(packageVersion, 256)
["libraryVersion"] = Truncate(packageVersion, Constants.MaxFieldLength)
},
["surface"] = Constants.Surface
};
}

private static string Truncate(string s, int maxLen)
{
if (s == null || s.Length <= maxLen)
if (s.Length <= maxLen)
return s;
return s.Substring(0, maxLen);
}

private static void TruncateStringValues(Dictionary<string, object> dict)
{
// Snapshot keys to avoid mutating the collection during iteration.
var keys = new List<string>(dict.Keys);
foreach (var key in keys)
{
if (dict[key] is string s && s.Length > Constants.MaxFieldLength)
dict[key] = Truncate(s, Constants.MaxFieldLength);
}
}
}
}
Loading
Loading