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
13 changes: 12 additions & 1 deletion src/Packages/Audience/Runtime/ImmutableAudience.cs
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,10 @@ public static void Init(AudienceConfig config)
// usually still empty when game_launch fires, so we ship a
// dedicated event after Init when the value first becomes
// observable. Idempotent across launches via an on-disk marker.
if (!string.IsNullOrEmpty(installReferrer))
// installReferrer encodes campaign attribution source, same privacy
// class as userId. Only ship at Full; don't write the sent marker
// at Anonymous so a later consent upgrade can fire the event.
if (!string.IsNullOrEmpty(installReferrer) && consentAtInit.CanIdentify())
Comment thread
cursor[bot] marked this conversation as resolved.
FireInstallReferrerReceivedOnce(config, installReferrer!);
}

Expand Down Expand Up @@ -1130,10 +1133,18 @@ private static void FireGameLaunch(

// iOS ATT/IDFA snapshot — merged after Unity context so attribution
// keys are authoritative if both sources happen to set the same key.
// idfa and gaid are cross-app device identifiers, same privacy class
// as userId; gate them at Full-only. State-class keys (attStatus,
// gaidLimitAdTracking) are non-identifying and ship at Anon+Full.
if (attributionContext != null)
{
var canIdentify = consentAtInit.CanIdentify();
foreach (var kvp in attributionContext)
{
if ((kvp.Key == "idfa" || kvp.Key == "gaid") && !canIdentify)
continue;
properties[kvp.Key] = kvp.Value;
}
}

// No sessionId on game_launch per Event Reference. Pipeline correlates
Expand Down
130 changes: 122 additions & 8 deletions src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1302,7 +1302,7 @@
["attStatus"] = "authorized",
["idfa"] = "11111111-2222-3333-4444-555555555555",
};
var config = MakeConfig();
var config = MakeConfig(ConsentLevel.Full);
config.EnableMobileAttribution = true;
ImmutableAudience.Init(config);
ImmutableAudience.Shutdown();
Expand Down Expand Up @@ -1423,7 +1423,7 @@
["gaid"] = "abcdef01-2345-6789-abcd-ef0123456789",
["gaidLimitAdTracking"] = false,
};
var config = MakeConfig();
var config = MakeConfig(ConsentLevel.Full);
config.EnableMobileAttribution = true;
ImmutableAudience.Init(config);
ImmutableAudience.Shutdown();
Expand Down Expand Up @@ -1459,6 +1459,64 @@
"gaid must not appear when the user has opted out");
}

// -----------------------------------------------------------------
// Consent-tier tightening: idfa, gaid => Full-only
//
// idfa and gaid are cross-app device identifiers, same privacy class
// as userId. They ship only when consent is Full. State-class keys
// (attStatus, gaidLimitAdTracking) are non-identifying and ship at
// Anonymous+Full (CanTrack).
// -----------------------------------------------------------------

[Test]
public void Init_GameLaunch_StripsIdfa_WhenConsentAnonymous()
{
ImmutableAudience.MobileAttributionContextProvider = () =>
new Dictionary<string, object>
{
["attStatus"] = "authorized",
["idfa"] = "11111111-2222-3333-4444-555555555555",
};
var config = MakeConfig(ConsentLevel.Anonymous);
config.EnableMobileAttribution = true;
ImmutableAudience.Init(config);
ImmutableAudience.Shutdown();

var launchFile = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json")
.Select(File.ReadAllText)
.First(c => c.Contains("\"game_launch\""));
StringAssert.Contains("\"attStatus\":\"authorized\"", launchFile,
"attStatus must ship at Anonymous: it is non-identifying state");
Assert.IsFalse(launchFile.Contains("\"idfa\""),
"idfa must not ship at Anonymous: it is a cross-app device identifier");
}

[Test]
public void Init_GameLaunch_StripsGaid_WhenConsentAnonymous()
{
// gaid is stripped at Anonymous; gaidLimitAdTracking is non-identifying
// state and must still ship so the pipeline can distinguish
// "fetched, opted out" from "not fetched yet".
ImmutableAudience.MobileAttributionContextProvider = () =>
new Dictionary<string, object>
{
["gaid"] = "abcdef01-2345-6789-abcd-ef0123456789",
["gaidLimitAdTracking"] = false,
};
var config = MakeConfig(ConsentLevel.Anonymous);
config.EnableMobileAttribution = true;
ImmutableAudience.Init(config);
ImmutableAudience.Shutdown();

var launchFile = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json")
.Select(File.ReadAllText)
.First(c => c.Contains("\"game_launch\""));
StringAssert.Contains("\"gaidLimitAdTracking\":false", launchFile,
"gaidLimitAdTracking must ship at Anonymous: it is non-identifying state");
Assert.IsFalse(launchFile.Contains("\"gaid\""),
"gaid must not ship at Anonymous: it is a cross-app device identifier");
}

// -----------------------------------------------------------------
// install_referrer_received
//
Expand All @@ -1473,7 +1531,7 @@
{
ImmutableAudience.MobileInstallReferrerProvider = () =>
"utm_source=google-play&utm_medium=organic";
var config = MakeConfig();
var config = MakeConfig(ConsentLevel.Full);
config.EnableMobileAttribution = true;
ImmutableAudience.Init(config);
ImmutableAudience.Shutdown();
Expand All @@ -1492,7 +1550,7 @@
// installReferrer is exclusively on the dedicated event; ensure
// we don't regress and start leaking it onto game_launch.
ImmutableAudience.MobileInstallReferrerProvider = () => "utm_source=test";
var config = MakeConfig();
var config = MakeConfig(ConsentLevel.Full);
config.EnableMobileAttribution = true;
ImmutableAudience.Init(config);
ImmutableAudience.Shutdown();
Expand Down Expand Up @@ -1544,7 +1602,7 @@
// Simulate the second launch: cache is populated, marker is set
// by the previous Init. Event must not refire.
ImmutableAudience.MobileInstallReferrerProvider = () => "utm_source=test";
var config = MakeConfig();
var config = MakeConfig(ConsentLevel.Full);
config.EnableMobileAttribution = true;

ImmutableAudience.Init(config);
Expand All @@ -1554,7 +1612,7 @@
var queueDir = AudiencePaths.QueueDir(_testDir);
foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f);

var config2 = MakeConfig();
var config2 = MakeConfig(ConsentLevel.Full);
config2.EnableMobileAttribution = true;
ImmutableAudience.Init(config2);
ImmutableAudience.Shutdown();
Expand Down Expand Up @@ -1592,13 +1650,13 @@
// First launch: bridge fetch in flight, provider returns null.
// Second launch: cache populated, provider returns the referrer.
// Event must fire on the second Init even though it missed the first.
string? firstCallReturn = null;

Check warning on line 1653 in src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs

View workflow job for this annotation

GitHub Actions / Unit Tests (.NET)

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

Check warning on line 1653 in src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs

View workflow job for this annotation

GitHub Actions / Unit Tests (.NET)

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
string? secondCallReturn = "utm_source=second_launch";

Check warning on line 1654 in src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs

View workflow job for this annotation

GitHub Actions / Unit Tests (.NET)

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

Check warning on line 1654 in src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs

View workflow job for this annotation

GitHub Actions / Unit Tests (.NET)

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
var callCount = 0;
ImmutableAudience.MobileInstallReferrerProvider = () =>
++callCount == 1 ? firstCallReturn : secondCallReturn;

var config = MakeConfig();
var config = MakeConfig(ConsentLevel.Full);
config.EnableMobileAttribution = true;
ImmutableAudience.Init(config);
ImmutableAudience.Shutdown();
Expand All @@ -1612,7 +1670,7 @@

foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f);

var config2 = MakeConfig();
var config2 = MakeConfig(ConsentLevel.Full);
config2.EnableMobileAttribution = true;
ImmutableAudience.Init(config2);
ImmutableAudience.Shutdown();
Expand Down Expand Up @@ -1643,6 +1701,62 @@
Assert.IsFalse(blobs.Any(c => c.Contains("\"install_referrer_received\"")));
}

[Test]
public void Init_DoesNotFireInstallReferrerReceived_WhenConsentAnonymous()
{
// installReferrer encodes campaign attribution source; Full-only.
// The sent marker must NOT be written so a later upgrade to Full
// can fire the event.
ImmutableAudience.MobileInstallReferrerProvider = () => "utm_source=google-play";
var config = MakeConfig(ConsentLevel.Anonymous);
config.EnableMobileAttribution = true;
ImmutableAudience.Init(config);
ImmutableAudience.Shutdown();

var blobs = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json")
.Select(File.ReadAllText).ToList();
Assert.IsFalse(blobs.Any(c => c.Contains("\"install_referrer_received\"")),
"install_referrer_received must not fire when consent is Anonymous");
Assert.IsFalse(File.Exists(AudiencePaths.InstallReferrerSentFile(_testDir)),
"sent marker must not be written at Anonymous so a Full upgrade can fire the event");
}

[Test]
public void Init_FiresInstallReferrerReceived_AfterConsentUpgradedToFull()
{
// First launch at Anonymous: referrer is available but event is
// gated; no event fires and no sent marker is written.
// Second launch at Full: event fires and marker is written.
ImmutableAudience.MobileInstallReferrerProvider = () => "utm_source=upgrade_test";

var config = MakeConfig(ConsentLevel.Anonymous);
config.EnableMobileAttribution = true;
ImmutableAudience.Init(config);
ImmutableAudience.Shutdown();

var queueDir = AudiencePaths.QueueDir(_testDir);
var firstBlobs = Directory.GetFiles(queueDir, "*.json")
.Select(File.ReadAllText).ToList();
Assert.IsFalse(firstBlobs.Any(c => c.Contains("\"install_referrer_received\"")),
"event must not ship on first launch when consent is Anonymous");
Assert.IsFalse(File.Exists(AudiencePaths.InstallReferrerSentFile(_testDir)),
"sent marker must not exist after Anonymous launch");

foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f);

var config2 = MakeConfig(ConsentLevel.Full);
config2.EnableMobileAttribution = true;
ImmutableAudience.Init(config2);
ImmutableAudience.Shutdown();

var secondBlobs = Directory.GetFiles(queueDir, "*.json")
.Select(File.ReadAllText).ToList();
Assert.IsTrue(secondBlobs.Any(c =>
c.Contains("\"install_referrer_received\"") &&
c.Contains("\"installReferrer\":\"utm_source=upgrade_test\"")),
"event must fire on the first Full-consent launch after an Anonymous launch");
}

// -----------------------------------------------------------------
// RequestTrackingAuthorizationAsync
// -----------------------------------------------------------------
Expand Down
Loading