@@ -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}
0 commit comments