-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathInstallReferrerBridge.cs
More file actions
211 lines (192 loc) · 8.83 KB
/
Copy pathInstallReferrerBridge.cs
File metadata and controls
211 lines (192 loc) · 8.83 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
#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
{
/// <summary>
/// Bridge to Google Play's Install Referrer Library. The referrer
/// describes where the install came from (Play Store campaign, organic,
/// deep link, etc.) and is the highest-value attribution signal on
/// Android.
///
/// The Install Referrer service returns the same value for the lifetime
/// of the install (until uninstall), so we fetch once and cache to disk
/// (file missing = not yet fetched; file present = terminal state, with
/// empty content meaning known-no-referrer). First app launch likely
/// won't include the referrer in <c>game_launch</c> (the async fetch
/// usually completes after the launch event has fired); subsequent
/// launches read from cache.
/// </summary>
internal static class InstallReferrerBridge
{
// Test seams. Tests inject without touching disk or the Android API.
internal static Func<string, string?> ReadCachedImpl = ReadCachedFromDisk;
internal static Action<string> StartFetchImpl = StartFetchNative;
// Per-process gate so a second EnsureFetchStarted call during a
// single session doesn't double up the Android Java connection.
private static int _fetchStarted;
/// <summary>
/// Returns the cached install referrer, or null if not yet fetched
/// or the device reported no referrer.
/// </summary>
internal static string? GetCachedInstallReferrer(string persistentDataPath)
{
if (string.IsNullOrEmpty(persistentDataPath)) return null;
return ReadCachedImpl(persistentDataPath);
}
/// <summary>
/// Starts the async fetch from Google Play if no terminal cache entry
/// exists yet. Idempotent within a process; idempotent across launches
/// once a terminal state is cached.
/// </summary>
internal static void EnsureFetchStarted(string persistentDataPath)
{
if (string.IsNullOrEmpty(persistentDataPath)) return;
if (Interlocked.CompareExchange(ref _fetchStarted, 1, 0) != 0) return;
// If we already have a terminal cache entry, skip the fetch.
// The cache survives across launches; once written it never
// changes (Google's referrer is stable for the install).
if (File.Exists(AudiencePaths.InstallReferrerFile(persistentDataPath)))
return;
try
{
StartFetchImpl(persistentDataPath);
}
catch (Exception)
{
// Re-arm the gate so a later EnsureFetchStarted retries.
Interlocked.Exchange(ref _fetchStarted, 0);
throw;
}
}
// Test-only reset; production code never calls this. Lets fixtures
// start each test from a clean state (cache file + in-process gate).
internal static void ResetForTesting()
{
Interlocked.Exchange(ref _fetchStarted, 0);
}
private static string? ReadCachedFromDisk(string persistentDataPath)
{
try
{
var path = AudiencePaths.InstallReferrerFile(persistentDataPath);
if (!File.Exists(path)) return null;
var content = File.ReadAllText(path);
return string.IsNullOrEmpty(content) ? null : content;
}
catch (Exception)
{
return null;
}
}
// Writes a terminal cache entry. Empty string marks "fetched, no
// referrer" so subsequent launches don't re-call the service for a
// permanent no-data state (organic install, FEATURE_NOT_SUPPORTED,
// etc.). Transient errors must NOT call this.
internal static void WriteCacheEntry(string persistentDataPath, string referrerOrEmpty)
{
try
{
var path = AudiencePaths.InstallReferrerFile(persistentDataPath);
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
var tmp = path + ".tmp";
File.WriteAllText(tmp, referrerOrEmpty);
if (File.Exists(path)) File.Delete(path);
File.Move(tmp, path);
}
catch (Exception)
{
// Cache miss costs one extra service call next launch.
}
}
#if UNITY_ANDROID && AUDIENCE_MOBILE_ATTRIBUTION
// Response codes from com.android.installreferrer.api.InstallReferrerClient.InstallReferrerResponse.
private const int ResponseOk = 0;
private const int ResponseServiceUnavailable = 1;
private const int ResponseFeatureNotSupported = 2;
private const int ResponseDeveloperError = 3;
private const int ResponseServiceDisconnected = 4;
private const int ResponsePermissionError = 5;
private static void StartFetchNative(string persistentDataPath)
{
// currentActivity is the standard Unity → Android entry point.
// The Install Referrer client binds to Google Play and calls
// back on a worker thread; we never touch Unity APIs from there.
//
// Each AndroidJavaClass / AndroidJavaObject holds a JNI global
// reference; leaking them stranded a JNI handle every Init.
// `client` is the exception: ownership transfers to the
// listener which disposes it in its endConnection finally.
using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
using (var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"))
using (var clientClass = new AndroidJavaClass("com.android.installreferrer.api.InstallReferrerClient"))
using (var builder = clientClass.CallStatic<AndroidJavaObject>("newBuilder", activity))
{
var client = builder.Call<AndroidJavaObject>("build");
var listener = new ReferrerStateListener(client, persistentDataPath);
client.Call("startConnection", listener);
}
}
private class ReferrerStateListener : AndroidJavaProxy
{
private readonly AndroidJavaObject _client;
private readonly string _persistentDataPath;
public ReferrerStateListener(AndroidJavaObject client, string persistentDataPath)
: base("com.android.installreferrer.api.InstallReferrerStateListener")
{
_client = client;
_persistentDataPath = persistentDataPath;
}
// Java method name; AndroidJavaProxy dispatches by name.
public void onInstallReferrerSetupFinished(int responseCode)
{
try
{
switch (responseCode)
{
case ResponseOk:
using (var details = _client.Call<AndroidJavaObject>("getInstallReferrer"))
{
var referrer = details.Call<string>("getInstallReferrer");
WriteCacheEntry(_persistentDataPath, referrer ?? string.Empty);
}
break;
case ResponseFeatureNotSupported:
case ResponseDeveloperError:
case ResponsePermissionError:
// Permanent: never retry on this device/app.
WriteCacheEntry(_persistentDataPath, string.Empty);
break;
case ResponseServiceUnavailable:
case ResponseServiceDisconnected:
default:
// Transient: leave cache missing so next launch retries.
break;
}
}
finally
{
try { _client.Call("endConnection"); } catch { /* swallow */ }
_client.Dispose();
}
}
// The service can drop after a successful setup. We don't depend
// on the live connection (we already wrote the cache), so this
// is just a no-op.
public void onInstallReferrerServiceDisconnected() { }
}
#else
private static void StartFetchNative(string persistentDataPath)
{
// Editor / non-Android / build-time gate not set: no-op.
}
#endif
}
}