diff --git a/examples/audience/Assets/Plugins.meta b/examples/audience/Assets/Plugins.meta new file mode 100644 index 00000000..8ac17dd8 --- /dev/null +++ b/examples/audience/Assets/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 534f898df82b946809603784fb72739a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/audience/Assets/Plugins/Android.meta b/examples/audience/Assets/Plugins/Android.meta new file mode 100644 index 00000000..2aafca84 --- /dev/null +++ b/examples/audience/Assets/Plugins/Android.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 13c2daed7011c4a8eb35e6e4b9a89aee +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/audience/Assets/Plugins/Android/mainTemplate.gradle b/examples/audience/Assets/Plugins/Android/mainTemplate.gradle new file mode 100644 index 00000000..795cadd4 --- /dev/null +++ b/examples/audience/Assets/Plugins/Android/mainTemplate.gradle @@ -0,0 +1,79 @@ +// Custom gradle template for the Audience sample. +// +// Studios who enable AUDIENCE_MOBILE_ATTRIBUTION on Android need +// play-services-ads-identifier so the SDK's GAIDBridge can call +// AdvertisingIdClient.getAdvertisingIdInfo via JNI. Without this +// dependency the class is missing at runtime and `gaid` never lands +// on game_launch (the bridge logs ClassNotFoundException and exits). +// +// To replicate this in your own project: +// 1. Player Settings → Publishing Settings → enable "Custom Main +// Gradle Template". Unity will create this file at +// Assets/Plugins/Android/mainTemplate.gradle. +// 2. Add the play-services-ads-identifier line in the dependencies +// block below. +// +// Studios who do NOT enable AUDIENCE_MOBILE_ATTRIBUTION can omit the +// dependency entirely; the SDK's JNI code is stripped at compile time. + +apply plugin: 'com.android.library' +**APPLY_PLUGINS** + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) +**DEPS** + // Uncomment to enable GAID collection (requires AUDIENCE_MOBILE_ATTRIBUTION + // scripting define + AudienceConfig.EnableMobileAttribution). Without this + // line the SDK still builds and runs, but `gaid` / `gaidLimitAdTracking` + // never ship and a one-line ClassNotFoundException is logged on Init. + // Skip this line if your studio doesn't need install attribution. + // implementation 'com.google.android.gms:play-services-ads-identifier:18.0.1' +} + +android { + namespace "com.unity3d.player" + ndkPath "**NDKPATH**" + + compileSdkVersion **APIVERSION** + buildToolsVersion '**BUILDTOOLS**' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + defaultConfig { + minSdkVersion **MINSDKVERSION** + targetSdkVersion **TARGETSDKVERSION** + ndk { + abiFilters **ABIFILTERS** + } + versionCode **VERSIONCODE** + versionName '**VERSIONNAME**' + consumerProguardFiles 'proguard-unity.txt'**USER_PROGUARD** + } + + lintOptions { + abortOnError false + } + + aaptOptions { + noCompress = **NON_COMPRESSED_ASSETS** + unityStreamingAssets.tokenize(', ') + ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + }**SIGN** + + **PACKAGING_OPTIONS** + + buildTypes { + debug { + minifyEnabled **MINIFY_DEBUG** + proguardFiles getDefaultProguardFile('proguard-android.txt')**SIGN_CONFIG** + jniDebuggable true + } + release { + minifyEnabled **MINIFY_RELEASE** + proguardFiles getDefaultProguardFile('proguard-android.txt')**SIGN_CONFIG** + } + }**PACKAGING** +}**REPOSITORIES****SOURCE_BUILD_SETUP** +**IL_CPP_BUILD_SETUP** diff --git a/examples/audience/Assets/Plugins/Android/mainTemplate.gradle.meta b/examples/audience/Assets/Plugins/Android/mainTemplate.gradle.meta new file mode 100644 index 00000000..eb8d1f4b --- /dev/null +++ b/examples/audience/Assets/Plugins/Android/mainTemplate.gradle.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 18f54af952c1847ea9f566dc6ba76ca8 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/audience/ProjectSettings/ProjectSettings.asset b/examples/audience/ProjectSettings/ProjectSettings.asset index ebc4caf7..7cfafd9a 100644 --- a/examples/audience/ProjectSettings/ProjectSettings.asset +++ b/examples/audience/ProjectSettings/ProjectSettings.asset @@ -164,7 +164,7 @@ PlayerSettings: Standalone: 0 iPhone: 0 tvOS: 0 - overrideDefaultApplicationIdentifier: 0 + overrideDefaultApplicationIdentifier: 1 AndroidBundleVersionCode: 1 AndroidMinSdkVersion: 22 AndroidTargetSdkVersion: 0 @@ -244,7 +244,7 @@ PlayerSettings: templateDefaultScene: Assets/Scenes/SampleScene.unity useCustomMainManifest: 0 useCustomLauncherManifest: 0 - useCustomMainGradleTemplate: 0 + useCustomMainGradleTemplate: 1 useCustomLauncherGradleManifest: 0 useCustomBaseGradleTemplate: 0 useCustomGradlePropertiesTemplate: 0 @@ -809,6 +809,7 @@ PlayerSettings: webGLDecompressionFallback: 0 webGLPowerPreference: 2 scriptingDefineSymbols: + Android: AUDIENCE_MOBILE_ATTRIBUTION iPhone: AUDIENCE_MOBILE_ATTRIBUTION additionalCompilerArguments: {} platformArchitecture: {} diff --git a/src/Packages/Audience/Editor/AndroidManifestPostProcessor.cs b/src/Packages/Audience/Editor/AndroidManifestPostProcessor.cs index f7f1b78d..75d026e8 100644 --- a/src/Packages/Audience/Editor/AndroidManifestPostProcessor.cs +++ b/src/Packages/Audience/Editor/AndroidManifestPostProcessor.cs @@ -6,12 +6,15 @@ namespace Immutable.Audience.Editor { - // Injects android.permission.INTERNET into the generated unityLibrary manifest. + // Injects android.permission.INTERNET into the generated unityLibrary + // manifest. The SDK sends events via System.Net.Http.HttpClient (not + // UnityWebRequest), so Unity does not auto-add INTERNET. // - // The SDK sends events via System.Net.Http.HttpClient, not UnityWebRequest, so - // Unity does not auto-add INTERNET. This post-processor ensures the permission - // is always present regardless of how the package is installed (file:, git, or - // UPM registry), without requiring the studio to set ForceInternetPermission. + // AD_ID is intentionally NOT injected here. It comes from the + // play-services-ads-identifier AAR's own manifest via AGP merging when + // the studio adds the Maven dependency. Injecting it ourselves would + // declare the permission for studios who never pull in the AAR (and so + // can never collect GAID), creating a Play Store Data Safety mismatch. internal sealed class AndroidManifestPostProcessor : IPostGenerateGradleAndroidProject { private const string InternetPermission = "android.permission.INTERNET"; diff --git a/src/Packages/Audience/Runtime/Core/AudiencePaths.cs b/src/Packages/Audience/Runtime/Core/AudiencePaths.cs index c8cc0052..82b20f58 100644 --- a/src/Packages/Audience/Runtime/Core/AudiencePaths.cs +++ b/src/Packages/Audience/Runtime/Core/AudiencePaths.cs @@ -10,6 +10,7 @@ internal static class AudiencePaths private const string QueueDirName = "queue"; private const string InstallReferrerFileName = "install_referrer"; private const string InstallReferrerSentFileName = "install_referrer_sent"; + private const string GAIDFileName = "gaid"; internal static string AudienceDir(string persistentDataPath) => Path.Combine(persistentDataPath, RootDirName); @@ -28,5 +29,8 @@ internal static string InstallReferrerFile(string persistentDataPath) => internal static string InstallReferrerSentFile(string persistentDataPath) => Path.Combine(AudienceDir(persistentDataPath), InstallReferrerSentFileName); + + internal static string GAIDFile(string persistentDataPath) => + Path.Combine(AudienceDir(persistentDataPath), GAIDFileName); } } diff --git a/src/Packages/Audience/Runtime/Plugins/Android/proguard-user.txt b/src/Packages/Audience/Runtime/Plugins/Android/proguard-user.txt index 96ea0570..f90a9b30 100644 --- a/src/Packages/Audience/Runtime/Plugins/Android/proguard-user.txt +++ b/src/Packages/Audience/Runtime/Plugins/Android/proguard-user.txt @@ -9,3 +9,12 @@ # minification regardless of fullMode behaviour. -keep class com.android.installreferrer.** { *; } -keep interface com.android.installreferrer.** { *; } + +# Keep Google Play Services AdvertisingIdClient symbols (GAID). +# +# AdvertisingIdClient and its inner Info class are reflected via JNI from +# GAIDBridge — they have no managed-side reference for R8 to follow, so +# fullMode in studio projects can drop them. Defensive rules ensure the +# getAdvertisingIdInfo / getId / isLimitAdTrackingEnabled surface survives. +-keep class com.google.android.gms.ads.identifier.** { *; } +-keep interface com.google.android.gms.ads.identifier.** { *; } diff --git a/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs b/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs index a26d16f1..1699e2d0 100644 --- a/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs +++ b/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs @@ -43,6 +43,12 @@ private static void Install() #if UNITY_ANDROID && !UNITY_EDITOR ImmutableAudience.MobileInstallReferrerProvider = ProvideInstallReferrer; +#if AUDIENCE_MOBILE_ATTRIBUTION + // Gated on the define so a build that disables GAID at compile + // time can't read a stale cache file left over from a prior + // install where the define was on. + ImmutableAudience.MobileAttributionContextProvider = ProvideAndroidAttributionContext; +#endif #endif UnityLifecycleBridge.EnsureExists(); @@ -63,5 +69,22 @@ private static void Install() InstallReferrerBridge.EnsureFetchStarted(path!); return InstallReferrerBridge.GetCachedInstallReferrer(path!); } + +#if UNITY_ANDROID && !UNITY_EDITOR && AUDIENCE_MOBILE_ATTRIBUTION + // Kicks off a background GAID fetch for the next launch (Google + // requires getAdvertisingIdInfo run off the main thread) and returns + // whatever was cached by the previous launch. First launch returns + // an empty dict; launch #2+ ships gaid + gaidLimitAdTracking. + // Exceptions propagate to ImmutableAudience.Init's + // MobileAttributionContextProviderThrew handler. + private static IReadOnlyDictionary? ProvideAndroidAttributionContext() + { + var path = _persistentDataPath; + if (string.IsNullOrEmpty(path)) return AttributionContext.Capture(); + + GAIDBridge.EnsureFetchStarted(path!); + return AttributionContext.Capture(path); + } +#endif } } diff --git a/src/Packages/Audience/Runtime/Unity/Mobile/AttributionContext.cs b/src/Packages/Audience/Runtime/Unity/Mobile/AttributionContext.cs index 23197455..c1a97e07 100644 --- a/src/Packages/Audience/Runtime/Unity/Mobile/AttributionContext.cs +++ b/src/Packages/Audience/Runtime/Unity/Mobile/AttributionContext.cs @@ -4,10 +4,17 @@ namespace Immutable.Audience.Unity.Mobile { - // Builds the iOS attribution snapshot that ships on game_launch when - // EnableMobileAttribution is true. ATT status is always read; IDFA is - // only included when status is authorized (Apple returns the all-zeros - // UUID otherwise, which the native bridge filters to null). + // Builds the platform attribution snapshot that ships on game_launch when + // EnableMobileAttribution is true. + // + // iOS: ATT status is always read; IDFA is only included when status is + // authorized (Apple returns the all-zeros UUID otherwise, which the native + // bridge filters to null). + // + // Android: gaid + gaidLimitAdTracking are read from the GAIDBridge disk + // cache populated by the previous launch's background fetch (Google's + // AdvertisingIdClient is sync + must run off main thread, so first launch + // ships nothing — gaidLimitAdTracking shows up on launch #2 onwards). internal static class AttributionContext { // Maps Apple's ATTrackingManagerAuthorizationStatus to the wire @@ -25,17 +32,21 @@ internal static string AttStatusToString(int status) } } - // Always returns a non-null dictionary — at minimum - // { attStatus: "notDetermined" }. The provider field type is - // nullable for forward-compat with future implementations that may - // want to opt out, but this implementation never returns null. - internal static IReadOnlyDictionary Capture() + // persistentDataPath is required for Android (GAID disk cache); iOS + // ignores it. Returns a possibly-empty dict — never null — so callers + // can merge unconditionally. + internal static IReadOnlyDictionary Capture(string? persistentDataPath = null) { + var props = new Dictionary(); + +#if UNITY_IOS || UNITY_EDITOR + // Compiled in on iOS device builds AND in the editor (any target) + // so AttributionContextTests can drive Capture() via the ATTBridge + // test seams. Excluded on real Android device builds so attStatus + // never ships there. Native ATTBridge calls are themselves gated + // by #if UNITY_IOS, so non-iOS editor targets get the safe stubs. var status = ATTBridge.GetStatus(); - var props = new Dictionary - { - ["attStatus"] = AttStatusToString(status), - }; + props["attStatus"] = AttStatusToString(status); // Only ship IDFA when the user has authorized tracking. The native // bridge already returns null for the zero-UUID case, but gating @@ -47,8 +58,33 @@ internal static IReadOnlyDictionary Capture() if (!string.IsNullOrEmpty(idfa)) props["idfa"] = idfa!; } +#endif + +#if UNITY_ANDROID && !UNITY_EDITOR && AUDIENCE_MOBILE_ATTRIBUTION + // Gated on AUDIENCE_MOBILE_ATTRIBUTION so a build that disables + // GAID at compile time can't read a stale cache file written by + // a previous install where the define was on. + if (!string.IsNullOrEmpty(persistentDataPath)) + { + var info = GAIDBridge.GetCached(persistentDataPath!); + if (info.HasValue) + EmitGaidProps(info.Value, props); + } +#endif return props; } + + // Defensive emission gate: even if a stale cache from a pre-fix build + // retained a non-empty GAID under opt-out, this method never ships + // the raw identifier when LimitAdTracking is true. gaidLimitAdTracking + // always ships so the pipeline can distinguish "fetched, opted out" + // from "not fetched yet". + internal static void EmitGaidProps(GAIDInfo info, IDictionary props) + { + if (!info.LimitAdTracking && !string.IsNullOrEmpty(info.Gaid)) + props["gaid"] = info.Gaid; + props["gaidLimitAdTracking"] = info.LimitAdTracking; + } } } diff --git a/src/Packages/Audience/Runtime/Unity/Mobile/GAIDBridge.cs b/src/Packages/Audience/Runtime/Unity/Mobile/GAIDBridge.cs new file mode 100644 index 00000000..8c7fcb5b --- /dev/null +++ b/src/Packages/Audience/Runtime/Unity/Mobile/GAIDBridge.cs @@ -0,0 +1,168 @@ +#nullable enable + +using System; +using System.IO; +using System.Threading; +using Immutable.Audience; +#if UNITY_ANDROID && AUDIENCE_MOBILE_ATTRIBUTION +using UnityEngine; +#endif + +namespace Immutable.Audience.Unity.Mobile +{ + /// + /// Reads gaid + limitAdTracking via AdvertisingIdClient. Google requires + /// the call run off the main thread, so we dispatch on a dedicated worker + /// and cache the result to disk for the next launch. GAID can change + /// (user reset), so we refresh every launch — first launch ships nothing, + /// launch #2+ ships the previously-cached value. + /// + internal static class GAIDBridge + { + // Test seams. + internal static Func ReadCachedImpl = ReadCachedFromDisk; + internal static Action StartFetchImpl = StartFetchNative; + + // Per-process gate: one fetch per session. + private static int _fetchStarted; + + internal static GAIDInfo? GetCached(string persistentDataPath) + { + if (string.IsNullOrEmpty(persistentDataPath)) return null; + return ReadCachedImpl(persistentDataPath); + } + + internal static void EnsureFetchStarted(string persistentDataPath) + { + if (string.IsNullOrEmpty(persistentDataPath)) return; + if (Interlocked.CompareExchange(ref _fetchStarted, 1, 0) != 0) return; + + try + { + StartFetchImpl(persistentDataPath); + } + catch (Exception) + { + // Re-arm so a later EnsureFetchStarted retries. + Interlocked.Exchange(ref _fetchStarted, 0); + throw; + } + } + + // Test-only. + internal static void ResetForTesting() + { + Interlocked.Exchange(ref _fetchStarted, 0); + } + + // Cache: line 1 = gaid (empty on opt-out), line 2 = "1"|"0" for limit flag. + // File missing = no fetch yet. + internal static void WriteCacheEntry(string persistentDataPath, string gaidOrEmpty, bool limitAdTracking) + { + try + { + var path = AudiencePaths.GAIDFile(persistentDataPath); + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var content = (gaidOrEmpty ?? string.Empty) + "\n" + (limitAdTracking ? "1" : "0"); + var tmp = path + ".tmp"; + File.WriteAllText(tmp, content); + if (File.Exists(path)) File.Delete(path); + File.Move(tmp, path); + } + catch (Exception) + { + // Cache miss costs one wasted fetch on next launch. + } + } + + private static GAIDInfo? ReadCachedFromDisk(string persistentDataPath) + { + try + { + var path = AudiencePaths.GAIDFile(persistentDataPath); + if (!File.Exists(path)) return null; + + var lines = File.ReadAllText(path).Split('\n'); + var gaid = lines.Length > 0 ? lines[0] : string.Empty; + var limit = lines.Length > 1 && lines[1] == "1"; + + return new GAIDInfo(gaid, limit); + } + catch (Exception) + { + return null; + } + } + +#if UNITY_ANDROID && AUDIENCE_MOBILE_ATTRIBUTION + private static void StartFetchNative(string persistentDataPath) + { + // Dedicated Thread (not ThreadPool) so Attach/Detach pair on the + // same one-shot worker — ThreadPool reuse strands JVM state. + var thread = new Thread(() => FetchOnWorkerThread(persistentDataPath)) + { + IsBackground = true, + Name = "Audience.GAIDFetch", + }; + thread.Start(); + } + + private static void FetchOnWorkerThread(string persistentDataPath) + { + // Unity 2021 does not auto-attach managed threads to the JVM; + // first JNI call segfaults libunity.so without this. + AndroidJNI.AttachCurrentThread(); + try + { + using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) + using (var activity = unityPlayer.GetStatic("currentActivity")) + using (var clientClass = new AndroidJavaClass("com.google.android.gms.ads.identifier.AdvertisingIdClient")) + using (var info = clientClass.CallStatic("getAdvertisingIdInfo", activity)) + { + var gaid = info.Call("getId"); + var limit = info.Call("isLimitAdTrackingEnabled"); + // Honor the user's opt-out at the cache layer: never + // persist the raw GAID when isLimitAdTrackingEnabled + // returned true, even though Google's API still hands it + // back. AttributionContext also filters at emission, but + // dropping it here keeps an opted-out identifier off disk. + var cachedGaid = limit ? string.Empty : (gaid ?? string.Empty); + WriteCacheEntry(persistentDataPath, cachedGaid, limit); + } + } + catch (Exception ex) + { + // Play Services missing, network, or user disabled ads. + // Cache stays empty; next launch retries. + Log.Warn(AudienceLogs.GAIDFetchThrew(ex)); + } + finally + { + // After using-block disposal (DeleteGlobalRef needs an + // attached thread); detaching first would crash dispose. + AndroidJNI.DetachCurrentThread(); + } + } +#else + private static void StartFetchNative(string persistentDataPath) + { + // Editor / non-Android / define off: no-op. + } +#endif + } + + internal readonly struct GAIDInfo + { + internal readonly string Gaid; + internal readonly bool LimitAdTracking; + + internal GAIDInfo(string gaid, bool limitAdTracking) + { + Gaid = gaid; + LimitAdTracking = limitAdTracking; + } + } +} diff --git a/src/Packages/Audience/Runtime/Unity/Mobile/GAIDBridge.cs.meta b/src/Packages/Audience/Runtime/Unity/Mobile/GAIDBridge.cs.meta new file mode 100644 index 00000000..9bfca170 --- /dev/null +++ b/src/Packages/Audience/Runtime/Unity/Mobile/GAIDBridge.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 189447c8508a54568b9fb9fd96ed1115 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Runtime/Utility/Log.cs b/src/Packages/Audience/Runtime/Utility/Log.cs index 2be696d7..222c2ca7 100644 --- a/src/Packages/Audience/Runtime/Utility/Log.cs +++ b/src/Packages/Audience/Runtime/Utility/Log.cs @@ -156,5 +156,9 @@ internal static string MobileInstallReferrerProviderThrew(Exception ex) => internal static string InstallReferrerSentMarkerWriteFailed(Exception ex) => $"Failed to write install_referrer_sent marker: {ex.GetType().Name}: {ex.Message}. " + "install_referrer_received may re-fire on the next launch."; + + internal static string GAIDFetchThrew(Exception ex) => + $"GAID fetch threw {ex.GetType().Name}: {ex.Message}. " + + "gaid will not ship on game_launch this session; next launch retries."; } } diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 971e3dac..e8855cac 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -1410,6 +1410,55 @@ public void Init_GameLaunch_AttributionContextProviderThrows_DoesNotPreventEvent Assert.IsFalse(launchFile.Contains("attStatus")); } + [Test] + public void Init_GameLaunch_IncludesGaidAndLimitFlag_WhenContextProviderReturns() + { + // Android attribution shape: gaid + gaidLimitAdTracking. Ships + // from launch #2 onwards (Google's getAdvertisingIdInfo is async + // off-main-thread; first launch's cache is empty when game_launch + // fires). Test asserts the wire shape, not the async timing. + ImmutableAudience.MobileAttributionContextProvider = () => + new Dictionary + { + ["gaid"] = "abcdef01-2345-6789-abcd-ef0123456789", + ["gaidLimitAdTracking"] = false, + }; + var config = MakeConfig(); + 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("\"gaid\":\"abcdef01-2345-6789-abcd-ef0123456789\"", launchFile); + StringAssert.Contains("\"gaidLimitAdTracking\":false", launchFile); + } + + [Test] + public void Init_GameLaunch_OmitsGaid_WhenUserOptedOut() + { + // User opted out: gaid omitted, but gaidLimitAdTracking=true ships + // so the pipeline can distinguish "fetched, opted out" from + // "not fetched yet" (both absent). + ImmutableAudience.MobileAttributionContextProvider = () => + new Dictionary + { + ["gaidLimitAdTracking"] = true, + }; + var config = MakeConfig(); + 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\":true", launchFile); + Assert.IsFalse(launchFile.Contains("\"gaid\""), + "gaid must not appear when the user has opted out"); + } + // ----------------------------------------------------------------- // install_referrer_received // diff --git a/src/Packages/Audience/Tests/Runtime/Unity/ATTBridgeTests.cs b/src/Packages/Audience/Tests/Runtime/Unity/ATTBridgeTests.cs index 21b62261..be91edbd 100644 --- a/src/Packages/Audience/Tests/Runtime/Unity/ATTBridgeTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Unity/ATTBridgeTests.cs @@ -167,5 +167,36 @@ public void AttStatusToString_KnownValues() Assert.AreEqual("denied", AttributionContext.AttStatusToString(2)); Assert.AreEqual("authorized", AttributionContext.AttStatusToString(3)); } + + [Test] + public void EmitGaidProps_LimitAdTrackingTrue_OmitsRawGaid() + { + var props = new Dictionary(); + AttributionContext.EmitGaidProps(new GAIDInfo("aaaa-bbbb", limitAdTracking: true), props); + + Assert.IsFalse(props.ContainsKey("gaid"), + "must never ship the raw GAID when the user has opted out via isLimitAdTrackingEnabled"); + Assert.AreEqual(true, props["gaidLimitAdTracking"]); + } + + [Test] + public void EmitGaidProps_LimitAdTrackingFalse_ShipsGaid() + { + var props = new Dictionary(); + AttributionContext.EmitGaidProps(new GAIDInfo("aaaa-bbbb", limitAdTracking: false), props); + + Assert.AreEqual("aaaa-bbbb", props["gaid"]); + Assert.AreEqual(false, props["gaidLimitAdTracking"]); + } + + [Test] + public void EmitGaidProps_EmptyGaidLimitFalse_OmitsGaidKeepsFlag() + { + var props = new Dictionary(); + AttributionContext.EmitGaidProps(new GAIDInfo(string.Empty, limitAdTracking: false), props); + + Assert.IsFalse(props.ContainsKey("gaid")); + Assert.AreEqual(false, props["gaidLimitAdTracking"]); + } } } diff --git a/src/Packages/Audience/Tests/Runtime/Unity/GAIDBridgeTests.cs b/src/Packages/Audience/Tests/Runtime/Unity/GAIDBridgeTests.cs new file mode 100644 index 00000000..a2c0c81b --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/Unity/GAIDBridgeTests.cs @@ -0,0 +1,195 @@ +#nullable enable + +using System; +using System.IO; +using NUnit.Framework; +using Immutable.Audience.Unity.Mobile; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class GAIDBridgeTests + { + private string _testDir = null!; + private Func _originalReadCachedImpl = null!; + private Action _originalStartFetchImpl = null!; + + [SetUp] + public void SetUp() + { + _testDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_testDir); + + _originalReadCachedImpl = GAIDBridge.ReadCachedImpl; + _originalStartFetchImpl = GAIDBridge.StartFetchImpl; + GAIDBridge.ResetForTesting(); + } + + [TearDown] + public void TearDown() + { + GAIDBridge.ReadCachedImpl = _originalReadCachedImpl; + GAIDBridge.StartFetchImpl = _originalStartFetchImpl; + GAIDBridge.ResetForTesting(); + + if (Directory.Exists(_testDir)) + Directory.Delete(_testDir, recursive: true); + } + + // ----------------------------------------------------------------- + // GetCached + // ----------------------------------------------------------------- + + [Test] + public void GetCached_NoFile_ReturnsNull() + { + Assert.IsNull(GAIDBridge.GetCached(_testDir)); + } + + [Test] + public void GetCached_NonEmptyFile_ReturnsGaidAndFlag() + { + const string gaid = "abcdef01-2345-6789-abcd-ef0123456789"; + GAIDBridge.WriteCacheEntry(_testDir, gaid, limitAdTracking: false); + + var info = GAIDBridge.GetCached(_testDir); + + Assert.IsTrue(info.HasValue); + Assert.AreEqual(gaid, info!.Value.Gaid); + Assert.IsFalse(info.Value.LimitAdTracking); + } + + [Test] + public void GetCached_LimitAdTrackingTrue_PreservedAcrossWriteRead() + { + const string gaid = "00000000-0000-0000-0000-000000000000"; + GAIDBridge.WriteCacheEntry(_testDir, gaid, limitAdTracking: true); + + var info = GAIDBridge.GetCached(_testDir); + + Assert.IsTrue(info.HasValue); + Assert.IsTrue(info!.Value.LimitAdTracking); + } + + [Test] + public void GetCached_OptOutEntry_ReturnsEmptyGaidWithFlag() + { + // User opted out → empty gaid string + limitAdTracking=true. The + // pipeline reads "fetched, opted out" rather than "not fetched". + GAIDBridge.WriteCacheEntry(_testDir, string.Empty, limitAdTracking: true); + + var info = GAIDBridge.GetCached(_testDir); + + Assert.IsTrue(info.HasValue); + Assert.AreEqual(string.Empty, info!.Value.Gaid); + Assert.IsTrue(info.Value.LimitAdTracking); + } + + [Test] + public void GetCached_NullPath_ReturnsNull() + { + Assert.IsNull(GAIDBridge.GetCached(null!)); + Assert.IsNull(GAIDBridge.GetCached(string.Empty)); + } + + // ----------------------------------------------------------------- + // EnsureFetchStarted + // ----------------------------------------------------------------- + + [Test] + public void EnsureFetchStarted_FirstCall_InvokesFetch() + { + var fetchCalls = 0; + GAIDBridge.StartFetchImpl = _ => fetchCalls++; + + GAIDBridge.EnsureFetchStarted(_testDir); + + Assert.AreEqual(1, fetchCalls); + } + + [Test] + public void EnsureFetchStarted_CalledTwiceInSameProcess_FetchesOnce() + { + var fetchCalls = 0; + GAIDBridge.StartFetchImpl = _ => fetchCalls++; + + GAIDBridge.EnsureFetchStarted(_testDir); + GAIDBridge.EnsureFetchStarted(_testDir); + + Assert.AreEqual(1, fetchCalls, + "Per-process gate must prevent duplicate JNI workers in one session"); + } + + [Test] + public void EnsureFetchStarted_TerminalCacheExists_StillFetches() + { + // Unlike the install referrer (terminal once written), GAID can + // change (user reset) — we always refresh the cache for the next + // launch even when a value already exists. + GAIDBridge.WriteCacheEntry(_testDir, "stale-gaid", limitAdTracking: false); + + var fetchCalls = 0; + GAIDBridge.StartFetchImpl = _ => fetchCalls++; + + GAIDBridge.EnsureFetchStarted(_testDir); + + Assert.AreEqual(1, fetchCalls); + } + + [Test] + public void EnsureFetchStarted_StartFetchThrows_RearmsGate() + { + // A synchronous failure (e.g. JNI attach) must re-arm the gate + // so a later call this session can retry. + GAIDBridge.StartFetchImpl = _ => throw new InvalidOperationException("boom"); + + Assert.Throws(() => GAIDBridge.EnsureFetchStarted(_testDir)); + + var retryCalls = 0; + GAIDBridge.StartFetchImpl = _ => retryCalls++; + GAIDBridge.EnsureFetchStarted(_testDir); + + Assert.AreEqual(1, retryCalls); + } + + [Test] + public void EnsureFetchStarted_NullPath_NoOp() + { + var fetchCalls = 0; + GAIDBridge.StartFetchImpl = _ => fetchCalls++; + + GAIDBridge.EnsureFetchStarted(null!); + GAIDBridge.EnsureFetchStarted(string.Empty); + + Assert.AreEqual(0, fetchCalls); + } + + // ----------------------------------------------------------------- + // WriteCacheEntry + // ----------------------------------------------------------------- + + [Test] + public void WriteCacheEntry_CreatesAudienceDirIfMissing() + { + // First-launch attribution write must create the imtbl_audience/ + // directory; consent None never touches disk so the dir may not + // exist yet. + GAIDBridge.WriteCacheEntry(_testDir, "abc", limitAdTracking: false); + + Assert.IsNotNull(GAIDBridge.GetCached(_testDir)); + } + + [Test] + public void WriteCacheEntry_OverwritesExistingFile() + { + GAIDBridge.WriteCacheEntry(_testDir, "old-gaid", limitAdTracking: true); + GAIDBridge.WriteCacheEntry(_testDir, "new-gaid", limitAdTracking: false); + + var info = GAIDBridge.GetCached(_testDir); + + Assert.IsTrue(info.HasValue); + Assert.AreEqual("new-gaid", info!.Value.Gaid); + Assert.IsFalse(info.Value.LimitAdTracking); + } + } +} diff --git a/src/Packages/Audience/Tests/Runtime/Unity/GAIDBridgeTests.cs.meta b/src/Packages/Audience/Tests/Runtime/Unity/GAIDBridgeTests.cs.meta new file mode 100644 index 00000000..5019803b --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/Unity/GAIDBridgeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: da60a259f491343fb8c9c73caae34aec +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: