Skip to content

Commit b2fd9d3

Browse files
nattb8claude
andcommitted
feat(audience-sdk): add Android GAID collection (SDK-309)
Capture the Google Advertising ID and limitAdTracking flag via JNI to AdvertisingIdClient. Ships on game_launch when AUDIENCE_MOBILE_ATTRIBUTION is defined, EnableMobileAttribution is on, and the studio has added play-services-ads-identifier to their gradle dependencies. Async fetch runs on a dedicated worker thread; first launch ships nothing, launch #2 onwards ships the previously cached value. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f5b8a62 commit b2fd9d3

16 files changed

Lines changed: 627 additions & 20 deletions

File tree

examples/audience/Assets/Plugins.meta

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/audience/Assets/Plugins/Android.meta

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Custom gradle template for the Audience sample.
2+
//
3+
// Studios who enable AUDIENCE_MOBILE_ATTRIBUTION on Android need
4+
// play-services-ads-identifier so the SDK's GAIDBridge can call
5+
// AdvertisingIdClient.getAdvertisingIdInfo via JNI. Without this
6+
// dependency the class is missing at runtime and `gaid` never lands
7+
// on game_launch (the bridge logs ClassNotFoundException and exits).
8+
//
9+
// To replicate this in your own project:
10+
// 1. Player Settings → Publishing Settings → enable "Custom Main
11+
// Gradle Template". Unity will create this file at
12+
// Assets/Plugins/Android/mainTemplate.gradle.
13+
// 2. Add the play-services-ads-identifier line in the dependencies
14+
// block below.
15+
//
16+
// Studios who do NOT enable AUDIENCE_MOBILE_ATTRIBUTION can omit the
17+
// dependency entirely; the SDK's JNI code is stripped at compile time.
18+
19+
apply plugin: 'com.android.library'
20+
**APPLY_PLUGINS**
21+
22+
dependencies {
23+
implementation fileTree(dir: 'libs', include: ['*.jar'])
24+
**DEPS**
25+
// Uncomment to enable GAID collection (requires AUDIENCE_MOBILE_ATTRIBUTION
26+
// scripting define + AudienceConfig.EnableMobileAttribution). Without this
27+
// line the SDK still builds and runs, but `gaid` / `gaidLimitAdTracking`
28+
// never ship and a one-line ClassNotFoundException is logged on Init.
29+
// Skip this line if your studio doesn't need install attribution.
30+
// implementation 'com.google.android.gms:play-services-ads-identifier:18.0.1'
31+
}
32+
33+
android {
34+
namespace "com.unity3d.player"
35+
ndkPath "**NDKPATH**"
36+
37+
compileSdkVersion **APIVERSION**
38+
buildToolsVersion '**BUILDTOOLS**'
39+
40+
compileOptions {
41+
sourceCompatibility JavaVersion.VERSION_1_8
42+
targetCompatibility JavaVersion.VERSION_1_8
43+
}
44+
45+
defaultConfig {
46+
minSdkVersion **MINSDKVERSION**
47+
targetSdkVersion **TARGETSDKVERSION**
48+
ndk {
49+
abiFilters **ABIFILTERS**
50+
}
51+
versionCode **VERSIONCODE**
52+
versionName '**VERSIONNAME**'
53+
consumerProguardFiles 'proguard-unity.txt'**USER_PROGUARD**
54+
}
55+
56+
lintOptions {
57+
abortOnError false
58+
}
59+
60+
aaptOptions {
61+
noCompress = **NON_COMPRESSED_ASSETS** + unityStreamingAssets.tokenize(', ')
62+
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
63+
}**SIGN**
64+
65+
**PACKAGING_OPTIONS**
66+
67+
buildTypes {
68+
debug {
69+
minifyEnabled **MINIFY_DEBUG**
70+
proguardFiles getDefaultProguardFile('proguard-android.txt')**SIGN_CONFIG**
71+
jniDebuggable true
72+
}
73+
release {
74+
minifyEnabled **MINIFY_RELEASE**
75+
proguardFiles getDefaultProguardFile('proguard-android.txt')**SIGN_CONFIG**
76+
}
77+
}**PACKAGING**
78+
}**REPOSITORIES****SOURCE_BUILD_SETUP**
79+
**IL_CPP_BUILD_SETUP**

examples/audience/Assets/Plugins/Android/mainTemplate.gradle.meta

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/audience/ProjectSettings/ProjectSettings.asset

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ PlayerSettings:
164164
Standalone: 0
165165
iPhone: 0
166166
tvOS: 0
167-
overrideDefaultApplicationIdentifier: 0
167+
overrideDefaultApplicationIdentifier: 1
168168
AndroidBundleVersionCode: 1
169169
AndroidMinSdkVersion: 22
170170
AndroidTargetSdkVersion: 0
@@ -244,7 +244,7 @@ PlayerSettings:
244244
templateDefaultScene: Assets/Scenes/SampleScene.unity
245245
useCustomMainManifest: 0
246246
useCustomLauncherManifest: 0
247-
useCustomMainGradleTemplate: 0
247+
useCustomMainGradleTemplate: 1
248248
useCustomLauncherGradleManifest: 0
249249
useCustomBaseGradleTemplate: 0
250250
useCustomGradlePropertiesTemplate: 0
@@ -809,6 +809,7 @@ PlayerSettings:
809809
webGLDecompressionFallback: 0
810810
webGLPowerPreference: 2
811811
scriptingDefineSymbols:
812+
Android: AUDIENCE_MOBILE_ATTRIBUTION
812813
iPhone: AUDIENCE_MOBILE_ATTRIBUTION
813814
additionalCompilerArguments: {}
814815
platformArchitecture: {}

src/Packages/Audience/Editor/AndroidManifestPostProcessor.cs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@
66

77
namespace Immutable.Audience.Editor
88
{
9-
// Injects android.permission.INTERNET into the generated unityLibrary manifest.
9+
// Injects android.permission.INTERNET into the generated unityLibrary
10+
// manifest. The SDK sends events via System.Net.Http.HttpClient (not
11+
// UnityWebRequest), so Unity does not auto-add INTERNET.
1012
//
11-
// The SDK sends events via System.Net.Http.HttpClient, not UnityWebRequest, so
12-
// Unity does not auto-add INTERNET. This post-processor ensures the permission
13-
// is always present regardless of how the package is installed (file:, git, or
14-
// UPM registry), without requiring the studio to set ForceInternetPermission.
13+
// AD_ID is intentionally NOT injected here. It comes from the
14+
// play-services-ads-identifier AAR's own manifest via AGP merging when
15+
// the studio adds the Maven dependency. Injecting it ourselves would
16+
// declare the permission for studios who never pull in the AAR (and so
17+
// can never collect GAID), creating a Play Store Data Safety mismatch.
1518
internal sealed class AndroidManifestPostProcessor : IPostGenerateGradleAndroidProject
1619
{
1720
private const string InternetPermission = "android.permission.INTERNET";

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ internal static class AudiencePaths
1010
private const string QueueDirName = "queue";
1111
private const string InstallReferrerFileName = "install_referrer";
1212
private const string InstallReferrerSentFileName = "install_referrer_sent";
13+
private const string GAIDFileName = "gaid";
1314

1415
internal static string AudienceDir(string persistentDataPath) =>
1516
Path.Combine(persistentDataPath, RootDirName);
@@ -28,5 +29,8 @@ internal static string InstallReferrerFile(string persistentDataPath) =>
2829

2930
internal static string InstallReferrerSentFile(string persistentDataPath) =>
3031
Path.Combine(AudienceDir(persistentDataPath), InstallReferrerSentFileName);
32+
33+
internal static string GAIDFile(string persistentDataPath) =>
34+
Path.Combine(AudienceDir(persistentDataPath), GAIDFileName);
3135
}
3236
}

src/Packages/Audience/Runtime/Plugins/Android/proguard-user.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,12 @@
99
# minification regardless of fullMode behaviour.
1010
-keep class com.android.installreferrer.** { *; }
1111
-keep interface com.android.installreferrer.** { *; }
12+
13+
# Keep Google Play Services AdvertisingIdClient symbols (GAID).
14+
#
15+
# AdvertisingIdClient and its inner Info class are reflected via JNI from
16+
# GAIDBridge — they have no managed-side reference for R8 to follow, so
17+
# fullMode in studio projects can drop them. Defensive rules ensure the
18+
# getAdvertisingIdInfo / getId / isLimitAdTrackingEnabled surface survives.
19+
-keep class com.google.android.gms.ads.identifier.** { *; }
20+
-keep interface com.google.android.gms.ads.identifier.** { *; }

src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ private static void Install()
4343

4444
#if UNITY_ANDROID && !UNITY_EDITOR
4545
ImmutableAudience.MobileInstallReferrerProvider = ProvideInstallReferrer;
46+
#if AUDIENCE_MOBILE_ATTRIBUTION
47+
// Gated on the define so a build that disables GAID at compile
48+
// time can't read a stale cache file left over from a prior
49+
// install where the define was on.
50+
ImmutableAudience.MobileAttributionContextProvider = ProvideAndroidAttributionContext;
51+
#endif
4652
#endif
4753

4854
UnityLifecycleBridge.EnsureExists();
@@ -63,5 +69,22 @@ private static void Install()
6369
InstallReferrerBridge.EnsureFetchStarted(path!);
6470
return InstallReferrerBridge.GetCachedInstallReferrer(path!);
6571
}
72+
73+
#if UNITY_ANDROID && !UNITY_EDITOR && AUDIENCE_MOBILE_ATTRIBUTION
74+
// Kicks off a background GAID fetch for the next launch (Google
75+
// requires getAdvertisingIdInfo run off the main thread) and returns
76+
// whatever was cached by the previous launch. First launch returns
77+
// an empty dict; launch #2+ ships gaid + gaidLimitAdTracking.
78+
// Exceptions propagate to ImmutableAudience.Init's
79+
// MobileAttributionContextProviderThrew handler.
80+
private static IReadOnlyDictionary<string, object>? ProvideAndroidAttributionContext()
81+
{
82+
var path = _persistentDataPath;
83+
if (string.IsNullOrEmpty(path)) return AttributionContext.Capture();
84+
85+
GAIDBridge.EnsureFetchStarted(path!);
86+
return AttributionContext.Capture(path);
87+
}
88+
#endif
6689
}
6790
}

src/Packages/Audience/Runtime/Unity/Mobile/AttributionContext.cs

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,17 @@
44

55
namespace Immutable.Audience.Unity.Mobile
66
{
7-
// Builds the iOS attribution snapshot that ships on game_launch when
8-
// EnableMobileAttribution is true. ATT status is always read; IDFA is
9-
// only included when status is authorized (Apple returns the all-zeros
10-
// UUID otherwise, which the native bridge filters to null).
7+
// Builds the platform attribution snapshot that ships on game_launch when
8+
// EnableMobileAttribution is true.
9+
//
10+
// iOS: ATT status is always read; IDFA is only included when status is
11+
// authorized (Apple returns the all-zeros UUID otherwise, which the native
12+
// bridge filters to null).
13+
//
14+
// Android: gaid + gaidLimitAdTracking are read from the GAIDBridge disk
15+
// cache populated by the previous launch's background fetch (Google's
16+
// AdvertisingIdClient is sync + must run off main thread, so first launch
17+
// ships nothing — gaidLimitAdTracking shows up on launch #2 onwards).
1118
internal static class AttributionContext
1219
{
1320
// Maps Apple's ATTrackingManagerAuthorizationStatus to the wire
@@ -25,17 +32,21 @@ internal static string AttStatusToString(int status)
2532
}
2633
}
2734

28-
// Always returns a non-null dictionary — at minimum
29-
// { attStatus: "notDetermined" }. The provider field type is
30-
// nullable for forward-compat with future implementations that may
31-
// want to opt out, but this implementation never returns null.
32-
internal static IReadOnlyDictionary<string, object> Capture()
35+
// persistentDataPath is required for Android (GAID disk cache); iOS
36+
// ignores it. Returns a possibly-empty dict — never null — so callers
37+
// can merge unconditionally.
38+
internal static IReadOnlyDictionary<string, object> Capture(string? persistentDataPath = null)
3339
{
40+
var props = new Dictionary<string, object>();
41+
42+
#if UNITY_IOS || UNITY_EDITOR
43+
// Compiled in on iOS device builds AND in the editor (any target)
44+
// so AttributionContextTests can drive Capture() via the ATTBridge
45+
// test seams. Excluded on real Android device builds so attStatus
46+
// never ships there. Native ATTBridge calls are themselves gated
47+
// by #if UNITY_IOS, so non-iOS editor targets get the safe stubs.
3448
var status = ATTBridge.GetStatus();
35-
var props = new Dictionary<string, object>
36-
{
37-
["attStatus"] = AttStatusToString(status),
38-
};
49+
props["attStatus"] = AttStatusToString(status);
3950

4051
// Only ship IDFA when the user has authorized tracking. The native
4152
// bridge already returns null for the zero-UUID case, but gating
@@ -47,6 +58,28 @@ internal static IReadOnlyDictionary<string, object> Capture()
4758
if (!string.IsNullOrEmpty(idfa))
4859
props["idfa"] = idfa!;
4960
}
61+
#endif
62+
63+
#if UNITY_ANDROID && !UNITY_EDITOR && AUDIENCE_MOBILE_ATTRIBUTION
64+
// Gated on AUDIENCE_MOBILE_ATTRIBUTION so a build that disables
65+
// GAID at compile time can't read a stale cache file written by
66+
// a previous install where the define was on.
67+
if (!string.IsNullOrEmpty(persistentDataPath))
68+
{
69+
var info = GAIDBridge.GetCached(persistentDataPath!);
70+
if (info.HasValue)
71+
{
72+
var v = info.Value;
73+
// gaid is omitted when the user has opted out (empty
74+
// cached value). gaidLimitAdTracking still ships so the
75+
// pipeline can distinguish "fetched, opted out" from
76+
// "not fetched yet".
77+
if (!string.IsNullOrEmpty(v.Gaid))
78+
props["gaid"] = v.Gaid;
79+
props["gaidLimitAdTracking"] = v.LimitAdTracking;
80+
}
81+
}
82+
#endif
5083

5184
return props;
5285
}

0 commit comments

Comments
 (0)