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
6 changes: 6 additions & 0 deletions src/Packages/Audience/Runtime/Audience.Runtime.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
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.

Portability enforcement: com.immutable.audience.asmdef sets
noEngineReferences: true, which makes stray `using UnityEngine` in Core/,
Events/, Transport/, Utility/ fail to compile inside Unity. This
Compile Remove is the sibling check for the headless dotnet build.
Keep both.
-->
<ItemGroup>
<Compile Remove="Unity/**/*.cs" />
Expand Down
8 changes: 7 additions & 1 deletion src/Packages/Audience/Runtime/AudienceError.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@ namespace Immutable.Audience
{
public enum AudienceErrorCode
{
// An event batch failed to flush. Either a local storage read error (batch dropped) or a non-2xx/non-4xx server response — typically 5xx (batch retained and retried with backoff).
FlushFailed,
// Server rejected an event batch with a 4xx status. The batch was dropped; retrying will not help (typically indicates a malformed payload).
ValidationRejected,
// Failed to sync a consent change to the backend. The local consent level has already been applied; the server-side audit trail may be out of date.
ConsentSyncFailed,
NetworkError
// A network call failed (exception, timeout, or non-2xx response on data deletion). Event batches are retained for retry; data-delete requests are not retried automatically.
NetworkError,
// Failed to persist the consent level to disk. In-memory level still applied but will revert on next launch.
ConsentPersistFailed
}

public class AudienceError : Exception
Expand Down
46 changes: 39 additions & 7 deletions src/Packages/Audience/Runtime/ImmutableAudience.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ public static class ImmutableAudience
// PersistentDataPath on the config.
internal static Func<string>? DefaultPersistentDataPathProvider;

// AudienceUnityHooks sets this so game_launch can auto-include
// Unity context without the core referencing UnityEngine.
internal static Func<Dictionary<string, object>>? LaunchContextProvider;

// Starts the SDK. Call once at launch.
public static void Init(AudienceConfig config)
{
Expand Down Expand Up @@ -288,14 +292,16 @@ public static void Reset()
Identity.Reset(config.PersistentDataPath!);
}

// Ask the backend to erase this player's data.
public static void DeleteData(string? userId = null)
// Ask the backend to erase this player's data. Returns a task the
// caller can await to know when the request is acknowledged, or
// discard for fire-and-forget.
public static Task DeleteData(string? userId = null)
{
if (!_initialized) return;
if (!_initialized) return Task.CompletedTask;

var config = _config;
var client = _controlClient;
if (config == null || client == null) return;
if (config == null || client == null) return Task.CompletedTask;

string query;
if (!string.IsNullOrEmpty(userId))
Expand All @@ -307,7 +313,7 @@ public static void DeleteData(string? userId = null)
// Get, not GetOrCreate — a brand-new install must not register an ID just to delete it.
var anonymousId = Identity.Get(config.PersistentDataPath!);
if (string.IsNullOrEmpty(anonymousId))
return;
return Task.CompletedTask;
query = "anonymousId=" + Uri.EscapeDataString(anonymousId);
}

Expand All @@ -316,7 +322,7 @@ public static void DeleteData(string? userId = null)
var publishableKey = config.PublishableKey;
var cancellationToken = _shutdownCancellationSource?.Token ?? CancellationToken.None;

Task.Run(async () =>
return Task.Run(async () =>
{
try
{
Expand Down Expand Up @@ -389,6 +395,8 @@ public static void SetConsent(ConsentLevel level)
{
Log.Warn($"SetConsent — failed to persist consent level: {ex.GetType().Name}: {ex.Message}. " +
"In-memory level is updated but will revert on next launch.");
NotifyErrorCallback(config.OnError, AudienceErrorCode.ConsentPersistFailed,
$"Consent persist failed: {ex.Message}");
}

if (level == ConsentLevel.None)
Expand Down Expand Up @@ -550,6 +558,8 @@ public static void Shutdown()
// Shuts down (if initialised) and clears per-session state so a
// fresh Init starts clean. Used on test teardown and by Unity
// SubsystemRegistration to survive "disable domain reload".
// LaunchContextProvider is not cleared: AudienceUnityHooks
// re-assigns it on the same SubsystemRegistration call.
internal static void ResetState()
{
if (_initialized)
Expand Down Expand Up @@ -656,10 +666,32 @@ private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAt

var properties = new Dictionary<string, object>();

// Unity-side auto-detected context (platform, version, buildGuid,
// unityVersion) from AudienceUnityHooks. Core stays pure C#; the
// Unity layer fills these via LaunchContextProvider.
var provider = LaunchContextProvider;
if (provider != null)
{
Dictionary<string, object>? unityContext = null;
try { unityContext = provider(); }
catch (Exception ex)
{
Log.Warn($"LaunchContextProvider threw {ex.GetType().Name}: {ex.Message}. " +
"game_launch will ship without auto-detected Unity context.");
}

if (unityContext != null)
{
foreach (var kvp in unityContext)
properties[kvp.Key] = kvp.Value;
}
}

// Config-supplied distributionPlatform wins over any provider value;
// studios set it explicitly because Unity cannot auto-detect the store.
if (config.DistributionPlatform != null)
properties["distributionPlatform"] = config.DistributionPlatform;

// Device-derived fields (platform, version, buildGuid, unityVersion) land with DeviceCollector.
Track("game_launch", properties.Count > 0 ? properties : null);
}
}
Expand Down
35 changes: 35 additions & 0 deletions src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#nullable enable

using System.Collections.Generic;
using UnityEngine;

namespace Immutable.Audience.Unity
{
internal static class AudienceUnityHooks
{
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void Install()
{
// Clear surviving statics before re-wiring in case "disable domain reload" kept them alive.
ImmutableAudience.ResetState();

// -= then += so repeat SubsystemRegistration cycles don't stack subscriptions.
Application.quitting -= ImmutableAudience.Shutdown;
Application.quitting += ImmutableAudience.Shutdown;

ImmutableAudience.DefaultPersistentDataPathProvider = () => Application.persistentDataPath;
ImmutableAudience.LaunchContextProvider = BuildLaunchContext;

if (Log.Writer == null) Log.Writer = Debug.Log;
}

private static Dictionary<string, object> BuildLaunchContext() =>
new Dictionary<string, object>
{
["platform"] = Application.platform.ToString(),
["version"] = Application.version,
["buildGuid"] = Application.buildGUID,
["unityVersion"] = Application.unityVersion,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "Immutable.Audience.Unity",
"rootNamespace": "Immutable.Audience.Unity",
"references": ["Immutable.Audience.Runtime"],
"includePlatforms": ["Editor","LinuxStandalone64","macOSStandalone","WindowsStandalone64"],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
"noEngineReferences": true
}
30 changes: 30 additions & 0 deletions src/Packages/Audience/Tests/Runtime/DeleteDataTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
Expand Down Expand Up @@ -144,6 +145,35 @@ public void DeleteData_DoesNotCreateAnonymousIdFile()
"DeleteData must not create the anonymousId file as a side effect");
}

[Test]
public async Task DeleteData_ReturnsTask_ThatCompletesAfterRequest()
{
var handler = new CapturingHandler();
ImmutableAudience.Init(MakeConfig(handler));

var task = ImmutableAudience.DeleteData(userId: "player-42");
Assert.IsNotNull(task, "DeleteData must return a non-null Task");

// Await directly: no need for the RequestSent gate when the task
// already represents completion.
await task;

Assert.IsTrue(handler.Requests.Any(r => r.Method == HttpMethod.Delete),
"DELETE request must have been sent by the time the task completes");
}

[Test]
public void DeleteData_BeforeInit_ReturnsCompletedTask()
{
// Not initialised — must not throw, must return a completed Task.
ImmutableAudience.ResetState();

var task = ImmutableAudience.DeleteData(userId: "player-42");

Assert.IsNotNull(task);
Assert.IsTrue(task.IsCompleted, "DeleteData before Init must return an already-completed Task");
}

[Test]
public void DeleteData_ServerError_InvokesOnError()
{
Expand Down
88 changes: 88 additions & 0 deletions src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public void SetUp()
public void TearDown()
{
ImmutableAudience.ResetState();
ImmutableAudience.LaunchContextProvider = null;
ImmutableAudience.DefaultPersistentDataPathProvider = null;
Identity.Reset(_testDir);
if (Directory.Exists(_testDir))
Expand Down Expand Up @@ -712,6 +713,30 @@ public void ResetState_ClearsIdentityCache_AcrossInitWithDifferentPath()
}
}

[Test]
public void SetConsent_PersistFailure_SurfacesOnError()
{
// Pre-create a directory where ConsentStore.Save wants to place
// the consent file; File.Move then fails without disturbing
// Init's DiskStore or Identity paths.
var consentFile = AudiencePaths.ConsentFile(_testDir);
Directory.CreateDirectory(consentFile);

// Bag rather than single capture: ConsentPersistFailed fires
// synchronously on the caller thread, SyncConsentToBackend's
// Task.Run may also fire ConsentSyncFailed concurrently. Assert
// presence of the one under test rather than the last seen.
var errors = new System.Collections.Concurrent.ConcurrentBag<AudienceError>();
var config = MakeConfig(ConsentLevel.Anonymous);
config.OnError = err => errors.Add(err);

ImmutableAudience.Init(config);
ImmutableAudience.SetConsent(ConsentLevel.Full);

Assert.That(errors.Any(e => e.Code == AudienceErrorCode.ConsentPersistFailed),
Is.True, "OnError should receive ConsentPersistFailed for consent persist failure");
}

[Test]
public void SetConsent_PersistsAcrossInit()
{
Expand Down Expand Up @@ -776,6 +801,69 @@ public void Init_ConsentNone_DoesNotFireGameLaunch()
Assert.IsFalse(contents.Any(c => c.Contains("\"game_launch\"")));
}

[Test]
public void Init_GameLaunch_IncludesLaunchContextProviderFields()
{
ImmutableAudience.LaunchContextProvider = () => new Dictionary<string, object>
{
["platform"] = "WindowsPlayer",
["version"] = "1.2.3",
["buildGuid"] = "a1b2c3d4e5f6",
["unityVersion"] = "2022.3.20f1",
};

ImmutableAudience.Init(MakeConfig());
ImmutableAudience.Shutdown();

var queueDir = AudiencePaths.QueueDir(_testDir);
var launchFile = Directory.GetFiles(queueDir, "*.json")
.Select(File.ReadAllText)
.FirstOrDefault(c => c.Contains("\"game_launch\""));
Assert.IsNotNull(launchFile, "game_launch should have been enqueued");
StringAssert.Contains("\"platform\":\"WindowsPlayer\"", launchFile);
StringAssert.Contains("\"version\":\"1.2.3\"", launchFile);
StringAssert.Contains("\"buildGuid\":\"a1b2c3d4e5f6\"", launchFile);
StringAssert.Contains("\"unityVersion\":\"2022.3.20f1\"", launchFile);
}

[Test]
public void Init_GameLaunch_ConfigDistributionPlatformOverridesProvider()
{
ImmutableAudience.LaunchContextProvider = () => new Dictionary<string, object>
{
["distributionPlatform"] = "provider_value",
};

var config = MakeConfig();
config.DistributionPlatform = DistributionPlatforms.Steam;
ImmutableAudience.Init(config);
ImmutableAudience.Shutdown();

var queueDir = AudiencePaths.QueueDir(_testDir);
var launchFile = Directory.GetFiles(queueDir, "*.json")
.Select(File.ReadAllText)
.First(c => c.Contains("\"game_launch\""));
StringAssert.Contains("\"distributionPlatform\":\"steam\"", launchFile);
Assert.IsFalse(launchFile.Contains("provider_value"),
"config.DistributionPlatform should win over the provider's value");
}

[Test]
public void Init_GameLaunch_ProviderThrows_StillFiresEvent()
{
ImmutableAudience.LaunchContextProvider = () =>
throw new InvalidOperationException("provider exploded");

Assert.DoesNotThrow(() => ImmutableAudience.Init(MakeConfig()));
ImmutableAudience.Shutdown();

var queueDir = AudiencePaths.QueueDir(_testDir);
var contents = Directory.GetFiles(queueDir, "*.json")
.Select(File.ReadAllText).ToList();
Assert.IsTrue(contents.Any(c => c.Contains("\"game_launch\"")),
"game_launch must still ship when the context provider throws");
}

// -----------------------------------------------------------------
// Shutdown
// -----------------------------------------------------------------
Expand Down
31 changes: 31 additions & 0 deletions src/Packages/Audience/link.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!--
IL2CPP link.xml for com.immutable.audience.

Preserves types that IL2CPP might otherwise strip under aggressive
managed-code stripping. The SDK's own types are kept via full-assembly
preservation on Immutable.Audience.Runtime; BCL assemblies list only the
namespaces/types actually reached at runtime (HttpClient pipeline, gzip
when the scripting define is on).

Regenerate if the transport or serialisation stack gains a new
framework dependency.
-->
<linker>
<assembly fullname="Immutable.Audience.Runtime" preserve="all" />

<assembly fullname="System.Net.Http">
<type fullname="System.Net.Http.HttpClient" preserve="all" />
<type fullname="System.Net.Http.HttpClientHandler" preserve="all" />
<type fullname="System.Net.Http.HttpMessageHandler" preserve="all" />
<type fullname="System.Net.Http.HttpRequestMessage" preserve="all" />
<type fullname="System.Net.Http.HttpResponseMessage" preserve="all" />
<type fullname="System.Net.Http.StringContent" preserve="all" />
<type fullname="System.Net.Http.ByteArrayContent" preserve="all" />
<type fullname="System.Net.Http.Headers.MediaTypeHeaderValue" preserve="all" />
</assembly>

<assembly fullname="System.IO.Compression">
<type fullname="System.IO.Compression.GZipStream" preserve="all" />
<type fullname="System.IO.Compression.CompressionLevel" preserve="all" />
</assembly>
</linker>
7 changes: 7 additions & 0 deletions src/Packages/Audience/link.xml.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading