Skip to content

Commit f5b8a62

Browse files
authored
Merge pull request #753 from immutable/feat/sdk-310-android-install-referrer
feat(audience-sdk): Android Play Install Referrer support (SDK-310)
2 parents 156b0b8 + af03b5d commit f5b8a62

15 files changed

Lines changed: 765 additions & 6 deletions

src/Packages/Audience/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@ Press Play; `ImmutableAudience.Initialized` returns `true` and `AnonymousId` bec
4747
- Integration guide and API reference: <https://docs.immutable.com/docs/products/audience/unity-sdk>
4848
- Sample Unity project: [`examples/audience`](https://github.com/immutable/unity-immutable-sdk/tree/main/examples/audience)
4949

50+
## Vendored dependencies
51+
52+
The package vendors prebuilt third-party AARs for mobile attribution. They are only included in your build when the corresponding scripting define is set; without the define they're stripped via `defineConstraints` on the plugin meta files.
53+
54+
| File | Version | Source | Required define |
55+
| --- | --- | --- | --- |
56+
| `Runtime/Plugins/Android/installreferrer-2.2.aar` | 2.2 | [maven.google.com](https://maven.google.com/web/index.html#com.android.installreferrer:installreferrer:2.2) | `AUDIENCE_MOBILE_ATTRIBUTION` |
57+
58+
`Runtime/Plugins/Android/proguard-user.txt` ships explicit R8 keep rules for the Install Referrer Library. Unity's gradle build merges it automatically when the AAR is included.
59+
60+
Before tagging a release, check `maven.google.com` for newer versions of any vendored dependency and bump the pinned filename if needed.
61+
5062
## License
5163

5264
See the repository [LICENSE](https://github.com/immutable/unity-immutable-sdk/blob/main/LICENSE.md).

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ internal static class AudiencePaths
88
private const string IdentityFileName = "identity";
99
private const string ConsentFileName = "consent";
1010
private const string QueueDirName = "queue";
11+
private const string InstallReferrerFileName = "install_referrer";
12+
private const string InstallReferrerSentFileName = "install_referrer_sent";
1113

1214
internal static string AudienceDir(string persistentDataPath) =>
1315
Path.Combine(persistentDataPath, RootDirName);
@@ -20,5 +22,11 @@ internal static string ConsentFile(string persistentDataPath) =>
2022

2123
internal static string QueueDir(string persistentDataPath) =>
2224
Path.Combine(AudienceDir(persistentDataPath), QueueDirName);
25+
26+
internal static string InstallReferrerFile(string persistentDataPath) =>
27+
Path.Combine(AudienceDir(persistentDataPath), InstallReferrerFileName);
28+
29+
internal static string InstallReferrerSentFile(string persistentDataPath) =>
30+
Path.Combine(AudienceDir(persistentDataPath), InstallReferrerSentFileName);
2331
}
2432
}

src/Packages/Audience/Runtime/ImmutableAudience.cs

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ public static class ImmutableAudience
6363
// non-iOS platforms (the public API resolves to NotDetermined).
6464
internal static volatile Func<Task<int>>? TrackingAuthorizationRequestProvider;
6565

66+
// Called during Init when config.EnableMobileAttribution is true.
67+
// Returns the cached Android Play Install Referrer string, or null if
68+
// not yet cached (first launch, async fetch may complete after
69+
// game_launch fires) or none exists for this install. Set by the Unity
70+
// layer; null in pure-C# environments and on non-Android platforms.
71+
internal static volatile Func<string?>? MobileInstallReferrerProvider;
72+
6673
// Active session. Created at Init (or on upgrade from None) and disposed
6774
// on Shutdown or SetConsent(None). Volatile so OnPause/OnResume see
6875
// assignments from SetConsent without taking _initLock.
@@ -220,22 +227,35 @@ public static void Init(AudienceConfig config)
220227
sessionToStart?.Start();
221228

222229
// Consent gate before invoking attribution providers: SKAN
223-
// registration is a network side effect and IDFA / ATT status
224-
// reads are privacy-sensitive. CanTrack() == false (consent
225-
// None) means we have no licence to do either, regardless of
226-
// whether EnableMobileAttribution is set in config.
230+
// registration and Install Referrer fetch are network side
231+
// effects, and IDFA / ATT status reads are privacy-sensitive.
232+
// CanTrack() == false (consent None) means we have no licence
233+
// to run any of them, regardless of whether EnableMobileAttribution
234+
// is set in config.
227235
bool? skanRegistered = null;
228236
IReadOnlyDictionary<string, object>? attributionContext = null;
237+
string? installReferrer = null;
229238
if (config.EnableMobileAttribution && consentAtInit.CanTrack())
230239
{
231240
try { skanRegistered = MobileAttributionProvider?.Invoke(); }
232241
catch (Exception ex) { Log.Warn(AudienceLogs.MobileAttributionProviderThrew(ex)); }
233242

234243
try { attributionContext = MobileAttributionContextProvider?.Invoke(); }
235244
catch (Exception ex) { Log.Warn(AudienceLogs.MobileAttributionContextProviderThrew(ex)); }
245+
246+
try { installReferrer = MobileInstallReferrerProvider?.Invoke(); }
247+
catch (Exception ex) { Log.Warn(AudienceLogs.MobileInstallReferrerProviderThrew(ex)); }
236248
}
237249

238250
FireGameLaunch(config, consentAtInit, skanRegistered, attributionContext);
251+
252+
// Fires once per install. installReferrer lands asynchronously
253+
// from Google Play Services; on the first launch the cache is
254+
// usually still empty when game_launch fires, so we ship a
255+
// dedicated event after Init when the value first becomes
256+
// observable. Idempotent across launches via an on-disk marker.
257+
if (!string.IsNullOrEmpty(installReferrer))
258+
FireInstallReferrerReceivedOnce(config, installReferrer!);
239259
}
240260

241261
// Pause/Resume hooks for the Unity lifecycle bridge.
@@ -1120,5 +1140,36 @@ private static void FireGameLaunch(
11201140
// via eventTimestamp with the session_start that fires just before.
11211141
Track("game_launch", properties.Count > 0 ? properties : null);
11221142
}
1143+
1144+
// Fires install_referrer_received exactly once per install. Cache
1145+
// file presence alone isn't enough — on first launch the bridge may
1146+
// write the cache after Init has already run, so the event must be
1147+
// dispatched at the next Init that observes a cache hit. The on-disk
1148+
// "sent" marker provides idempotency across that boundary.
1149+
private static void FireInstallReferrerReceivedOnce(AudienceConfig config, string installReferrer)
1150+
{
1151+
var sentFile = AudiencePaths.InstallReferrerSentFile(config.PersistentDataPath!);
1152+
if (File.Exists(sentFile)) return;
1153+
1154+
Track("install_referrer_received", new Dictionary<string, object>
1155+
{
1156+
["installReferrer"] = installReferrer,
1157+
});
1158+
1159+
try
1160+
{
1161+
var dir = Path.GetDirectoryName(sentFile);
1162+
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
1163+
Directory.CreateDirectory(dir);
1164+
File.WriteAllText(sentFile, string.Empty);
1165+
}
1166+
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
1167+
{
1168+
// Marker write failed — the event will re-fire on the next
1169+
// launch. Pipeline-side dedup or the cost of one duplicate is
1170+
// less bad than never sending the event at all.
1171+
Log.Warn(AudienceLogs.InstallReferrerSentMarkerWriteFailed(ex));
1172+
}
1173+
}
11231174
}
11241175
}

src/Packages/Audience/Runtime/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.
Binary file not shown.

src/Packages/Audience/Runtime/Plugins/Android/installreferrer-2.2.aar.meta

Lines changed: 33 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Keep Google Play Install Referrer Library symbols.
2+
#
3+
# The AAR (installreferrer-2.2.aar) ships consumer rules in
4+
# META-INF/com.android.tools/proguard/proguard.txt and Unity's gradle build
5+
# auto-merges them, but R8 fullMode in Unity 2022.3+ has been observed to
6+
# discard merged consumer rules in some studio configurations. These
7+
# explicit rules are defensive: they ensure the JNI surface
8+
# (InstallReferrerClient, ReferrerDetails, the listener interface) survives
9+
# minification regardless of fullMode behaviour.
10+
-keep class com.android.installreferrer.** { *; }
11+
-keep interface com.android.installreferrer.** { *; }

src/Packages/Audience/Runtime/Plugins/Android/proguard-user.txt.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.

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ namespace Immutable.Audience.Unity
99
{
1010
internal static class AudienceUnityHooks
1111
{
12+
// Captured at SubsystemRegistration so the Install Referrer provider
13+
// (called from ImmutableAudience.Init on whatever thread the user
14+
// invokes it from) can read it without touching
15+
// Application.persistentDataPath off the main thread.
16+
private static string? _persistentDataPath;
17+
1218
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
1319
private static void Install()
1420
{
@@ -18,6 +24,7 @@ private static void Install()
1824
Application.quitting -= ImmutableAudience.Shutdown;
1925
Application.quitting += ImmutableAudience.Shutdown;
2026

27+
_persistentDataPath = Application.persistentDataPath;
2128
ImmutableAudience.DefaultPersistentDataPathProvider = () => Application.persistentDataPath;
2229

2330
// Captured once on main thread; ReadOnlyDictionary blocks downstream mutation.
@@ -34,9 +41,27 @@ private static void Install()
3441
ImmutableAudience.TrackingAuthorizationRequestProvider = () => ATTBridge.RequestAsync();
3542
#endif
3643

44+
#if UNITY_ANDROID && !UNITY_EDITOR
45+
ImmutableAudience.MobileInstallReferrerProvider = ProvideInstallReferrer;
46+
#endif
47+
3748
UnityLifecycleBridge.EnsureExists();
3849

3950
if (Log.Writer == null) Log.Writer = Debug.Log;
4051
}
52+
53+
// Warms the install referrer cache for the next launch and returns
54+
// the currently cached value if any. Returns null on first launch
55+
// (cache miss while async fetch is in flight) or when the device
56+
// reports no referrer for this install. Exceptions propagate to
57+
// ImmutableAudience.Init's MobileInstallReferrerProviderThrew handler.
58+
private static string? ProvideInstallReferrer()
59+
{
60+
var path = _persistentDataPath;
61+
if (string.IsNullOrEmpty(path)) return null;
62+
63+
InstallReferrerBridge.EnsureFetchStarted(path!);
64+
return InstallReferrerBridge.GetCachedInstallReferrer(path!);
65+
}
4166
}
4267
}

0 commit comments

Comments
 (0)