@advenue/react-native 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,11 +8,11 @@ engine.
8
8
  ## Install
9
9
 
10
10
  ```sh
11
- npx expo install @advenue/react-native @advenue/sdk-android @advenue/sdk-ios \
11
+ npx expo install @advenue/react-native \
12
12
  react-native-mmkv expo-secure-store
13
13
  ```
14
14
 
15
- `@advenue/sdk-android` and `@advenue/sdk-ios` are Expo native modules built by
15
+ The iOS and Android Expo native modules ship inside this package (`ios/`, `android/`) and are built by
16
16
  your app's own Gradle/Xcode build — no manual linking.
17
17
 
18
18
  ## Quick start
@@ -0,0 +1,22 @@
1
+ apply plugin: 'com.android.library'
2
+ apply plugin: 'org.jetbrains.kotlin.android'
3
+
4
+ group = 'expo.modules.advenue'
5
+ version = '0.0.0'
6
+
7
+ android {
8
+ namespace = 'expo.modules.advenue'
9
+ compileSdk = 35
10
+ defaultConfig {
11
+ minSdk = 24
12
+ }
13
+ }
14
+
15
+ dependencies {
16
+ implementation 'com.android.installreferrer:installreferrer:2.2'
17
+ implementation("com.google.android.gms:play-services-ads-identifier:18.0.1")
18
+ // App Set ID — non-advertising, developer-scoped id for first-party fraud/dedup (not ads).
19
+ implementation("com.google.android.gms:play-services-appset:16.0.2")
20
+ // Play Integrity Standard API — device attestation (G3.1n).
21
+ implementation("com.google.android.play:integrity:1.4.0")
22
+ }
@@ -0,0 +1,8 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <!-- Android 11+ package visibility for the Meta Install Referrer providers. -->
3
+ <queries>
4
+ <package android:name="com.facebook.katana" />
5
+ <package android:name="com.instagram.android" />
6
+ <package android:name="com.facebook.lite" />
7
+ </queries>
8
+ </manifest>
@@ -0,0 +1,354 @@
1
+ package expo.modules.advenue
2
+
3
+ import android.content.pm.ApplicationInfo
4
+ import com.android.installreferrer.api.InstallReferrerClient
5
+ import com.android.installreferrer.api.InstallReferrerStateListener
6
+ import com.google.android.gms.ads.identifier.AdvertisingIdClient
7
+ import com.google.android.gms.appset.AppSet
8
+ import com.google.android.play.core.integrity.IntegrityManagerFactory
9
+ import com.google.android.play.core.integrity.StandardIntegrityManager
10
+ import com.google.android.play.core.integrity.StandardIntegrityManager.PrepareIntegrityTokenRequest
11
+ import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenRequest
12
+ import expo.modules.kotlin.modules.Module
13
+ import expo.modules.kotlin.modules.ModuleDefinition
14
+ import expo.modules.kotlin.Promise
15
+
16
+ // Advenue Android native module. Fetches the Google Play Install Referrer; the
17
+ // referrer string is parsed in TS (@advenue/install-referrer).
18
+ class AdvenueAndroidModule : Module() {
19
+ // Standard Integrity token provider, warmed once at module init. Null when
20
+ // Play Integrity is unavailable (side-loaded APK, emulator, no Play Services).
21
+ // Warming is best-effort — a failure leaves this null and getIntegrityToken
22
+ // will reject with a clear error.
23
+ private var integrityProvider: StandardIntegrityManager.StandardIntegrityTokenProvider? = null
24
+
25
+ override fun definition() = ModuleDefinition {
26
+ Name("AdvenueAndroid")
27
+
28
+ // Warm the Standard Integrity token provider when the module is created.
29
+ // The Google Cloud project number is read from the application's <meta-data>
30
+ // tag in AndroidManifest.xml:
31
+ //
32
+ // <meta-data
33
+ // android:name="io.advenue.INTEGRITY_CLOUD_PROJECT_NUMBER"
34
+ // android:value="<your-GCP-project-number>" />
35
+ //
36
+ // This is the numeric project ID shown in the Google Cloud Console URL:
37
+ // https://console.cloud.google.com/home/dashboard?project=<project-id>
38
+ // The number itself lives in Settings → Project info → Project number.
39
+ OnCreate {
40
+ val ctx = appContext.reactContext?.applicationContext ?: return@OnCreate
41
+ try {
42
+ val ai: ApplicationInfo = ctx.packageManager.getApplicationInfo(
43
+ ctx.packageName,
44
+ android.content.pm.PackageManager.GET_META_DATA,
45
+ )
46
+ // GCP project numbers are large longs (12+ digits). Android meta-data
47
+ // stores them as String when declared as android:value="123..." (no type
48
+ // suffix). We try getLong() first (works when stored as an integer
49
+ // resource), then fall back to getString() + toLong() for string values.
50
+ val metaBundle = ai.metaData
51
+ val cloudProjectNumber: Long = metaBundle?.getLong(
52
+ "io.advenue.INTEGRITY_CLOUD_PROJECT_NUMBER", 0L
53
+ )?.takeIf { it != 0L }
54
+ ?: metaBundle?.getString("io.advenue.INTEGRITY_CLOUD_PROJECT_NUMBER")
55
+ ?.toLongOrNull()
56
+ ?: 0L
57
+ if (cloudProjectNumber == 0L) {
58
+ // Project number not configured — attestation will be unavailable.
59
+ // Callers can still call getIntegrityToken(); it will reject with ECONFIG.
60
+ return@OnCreate
61
+ }
62
+ val manager = IntegrityManagerFactory.createStandard(ctx)
63
+ manager.prepareIntegrityToken(
64
+ PrepareIntegrityTokenRequest.builder()
65
+ .setCloudProjectNumber(cloudProjectNumber)
66
+ .build()
67
+ )
68
+ .addOnSuccessListener { provider -> integrityProvider = provider }
69
+ .addOnFailureListener { /* provider stays null — getIntegrityToken will reject */ }
70
+ } catch (_: Exception) {
71
+ // Play Integrity unavailable (no Play Services, side-loaded build, etc.)
72
+ }
73
+ }
74
+
75
+ AsyncFunction("getInstallReferrer") { promise: Promise ->
76
+ val context = appContext.reactContext ?: run {
77
+ promise.reject("ECTX", "No React context", null)
78
+ return@AsyncFunction
79
+ }
80
+ val client = InstallReferrerClient.newBuilder(context).build()
81
+ // The Play Install Referrer callback can fire setup-finished, disconnect, or
82
+ // never respond (offline / unavailable). Settle the promise EXACTLY once and
83
+ // release the service binding on every exit path — otherwise a disconnect or
84
+ // a hung connection leaks the binding and hangs trackInstall forever.
85
+ val settled = java.util.concurrent.atomic.AtomicBoolean(false)
86
+ val handler = android.os.Handler(android.os.Looper.getMainLooper())
87
+ fun release() {
88
+ try { client.endConnection() } catch (_: Exception) {}
89
+ }
90
+ // Watchdog: if the service never calls back, resolve null (referrer is
91
+ // best-effort enrichment) so the JS promise always settles.
92
+ handler.postDelayed({
93
+ if (settled.compareAndSet(false, true)) {
94
+ release()
95
+ promise.resolve(null)
96
+ }
97
+ }, 5_000)
98
+
99
+ client.startConnection(object : InstallReferrerStateListener {
100
+ override fun onInstallReferrerSetupFinished(responseCode: Int) {
101
+ if (!settled.compareAndSet(false, true)) return
102
+ handler.removeCallbacksAndMessages(null)
103
+ try {
104
+ if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
105
+ val response = client.installReferrer
106
+ promise.resolve(mapOf(
107
+ "referrer" to response.installReferrer,
108
+ "clickTimestamp" to response.referrerClickTimestampSeconds,
109
+ "installTimestamp" to response.installBeginTimestampSeconds,
110
+ // Server-clock getters (G1): Play returns 0 when the install is
111
+ // unverified. The JS layer forwards these as server-trusted keys
112
+ // only when non-zero. Do NOT confuse with the device-clock
113
+ // *TimestampSeconds getters above.
114
+ "clickServerTimestamp" to response.referrerClickTimestampServerSeconds,
115
+ "installServerTimestamp" to response.installBeginTimestampServerSeconds,
116
+ "googlePlayInstant" to response.googlePlayInstantParam,
117
+ ))
118
+ } else {
119
+ promise.resolve(null)
120
+ }
121
+ } catch (e: Exception) {
122
+ promise.reject("EREFERRER", e.message, e)
123
+ } finally {
124
+ release()
125
+ }
126
+ }
127
+
128
+ override fun onInstallReferrerServiceDisconnected() {
129
+ // Service dropped before setup finished (e.g. low memory). Release the
130
+ // binding and settle as best-effort null instead of hanging forever.
131
+ if (settled.compareAndSet(false, true)) {
132
+ handler.removeCallbacksAndMessages(null)
133
+ release()
134
+ promise.resolve(null)
135
+ }
136
+ }
137
+ })
138
+ }
139
+
140
+ // Meta (Facebook/Instagram) install referrer via content provider. The
141
+ // payload is parsed in TS and the encrypted blob decrypted server-side
142
+ // (@advenue/install-referrer). Resolves null when no Meta app is installed.
143
+ AsyncFunction("getMetaInstallReferrer") { promise: Promise ->
144
+ val context = appContext.reactContext ?: run {
145
+ promise.reject("ECTX", "No React context", null)
146
+ return@AsyncFunction
147
+ }
148
+ val providers = listOf("com.facebook.katana", "com.instagram.android", "com.facebook.lite")
149
+ try {
150
+ for (pkg in providers) {
151
+ val uri = android.net.Uri.parse(
152
+ "content://$pkg.provider.InstallReferrerProvider/${context.packageName}"
153
+ )
154
+ val cursor = try {
155
+ context.contentResolver.query(
156
+ uri,
157
+ arrayOf("install_referrer", "is_ct", "actual_timestamp"),
158
+ null,
159
+ null,
160
+ null,
161
+ )
162
+ } catch (_: Exception) {
163
+ null
164
+ } ?: continue
165
+ cursor.use {
166
+ if (it.moveToFirst()) {
167
+ val referrerIdx = it.getColumnIndex("install_referrer")
168
+ val ctIdx = it.getColumnIndex("is_ct")
169
+ val tsIdx = it.getColumnIndex("actual_timestamp")
170
+ if (referrerIdx >= 0) {
171
+ promise.resolve(mapOf(
172
+ "installReferrer" to it.getString(referrerIdx),
173
+ "isClickThrough" to (ctIdx >= 0 && it.getInt(ctIdx) == 1),
174
+ "actualTimestamp" to (if (tsIdx >= 0) it.getLong(tsIdx) else 0L),
175
+ "provider" to pkg,
176
+ ))
177
+ return@AsyncFunction
178
+ }
179
+ }
180
+ }
181
+ }
182
+ promise.resolve(null)
183
+ } catch (e: Exception) {
184
+ promise.reject("EMETAREF", e.message, e)
185
+ }
186
+ }
187
+
188
+ // Google Advertising ID (GAID) + limit-ad-tracking flag. The blocking
189
+ // Play Services call is run on a background thread.
190
+ AsyncFunction("getAdvertisingId") { promise: Promise ->
191
+ Thread {
192
+ try {
193
+ val ctx = appContext.reactContext?.applicationContext
194
+ if (ctx == null) { promise.resolve(null); return@Thread }
195
+ val info = AdvertisingIdClient.getAdvertisingIdInfo(ctx)
196
+ val id = info.id
197
+ val zeroGaid = "00000000-0000-0000-0000-000000000000"
198
+ if (id.isNullOrEmpty() || id == zeroGaid) promise.resolve(null)
199
+ else promise.resolve(mapOf("id" to id, "limitAdTracking" to info.isLimitAdTrackingEnabled))
200
+ } catch (e: Exception) {
201
+ promise.resolve(null)
202
+ }
203
+ }.start()
204
+ }
205
+
206
+ // App Set ID — non-advertising, developer-scoped id. First-party fraud/dedup
207
+ // use only; Google forbids using it for ads or measurement.
208
+ AsyncFunction("getAppSetId") { promise: Promise ->
209
+ val ctx = appContext.reactContext?.applicationContext
210
+ if (ctx == null) { promise.resolve(null); return@AsyncFunction }
211
+ AppSet.getClient(ctx).appSetIdInfo
212
+ .addOnSuccessListener { info -> promise.resolve(info.id) }
213
+ .addOnFailureListener { promise.resolve(null) }
214
+ }
215
+
216
+ // Play Integrity Standard API — device attestation (G3.1n).
217
+ //
218
+ // `requestHash` MUST be sha256_hex("<appId>:<deviceId>") — lowercase hex of
219
+ // SHA-256 over the literal string "<appId>:<deviceId>". The server's
220
+ // mapIntegrityVerdict (Task 3) re-derives the same string and compares it to
221
+ // the token's requestHash field; the strings must match byte-for-byte.
222
+ //
223
+ // The provider is warmed in OnCreate (above). If it is null — because the
224
+ // manifest meta-data key is absent, Play Integrity is unsupported, or warming
225
+ // failed — this rejects with ECONFIG / EUNSUPPORTED. The JS layer treats any
226
+ // rejection as "no attestation" and omits the token from the install event.
227
+ AsyncFunction("getIntegrityToken") { requestHash: String, promise: Promise ->
228
+ val provider = integrityProvider
229
+ if (provider == null) {
230
+ // Distinguish "project number not set" from "Play Integrity not available".
231
+ val ctx = appContext.reactContext?.applicationContext
232
+ if (ctx != null) {
233
+ val ai = try {
234
+ ctx.packageManager.getApplicationInfo(
235
+ ctx.packageName,
236
+ android.content.pm.PackageManager.GET_META_DATA,
237
+ )
238
+ } catch (_: Exception) { null }
239
+ val mb = ai?.metaData
240
+ val cfgNum: Long = mb?.getLong("io.advenue.INTEGRITY_CLOUD_PROJECT_NUMBER", 0L)
241
+ ?.takeIf { it != 0L }
242
+ ?: mb?.getString("io.advenue.INTEGRITY_CLOUD_PROJECT_NUMBER")?.toLongOrNull()
243
+ ?: 0L
244
+ if (cfgNum == 0L) {
245
+ promise.reject(
246
+ "ECONFIG",
247
+ "Play Integrity not configured: add io.advenue.INTEGRITY_CLOUD_PROJECT_NUMBER to AndroidManifest.xml meta-data",
248
+ null,
249
+ )
250
+ return@AsyncFunction
251
+ }
252
+ }
253
+ promise.reject(
254
+ "EUNSUPPORTED",
255
+ "Play Integrity token provider not ready (Play Services may be unavailable or warming failed)",
256
+ null,
257
+ )
258
+ return@AsyncFunction
259
+ }
260
+
261
+ val request = StandardIntegrityTokenRequest.builder()
262
+ .setRequestHash(requestHash)
263
+ .build()
264
+
265
+ provider.request(request)
266
+ .addOnSuccessListener { response -> promise.resolve(response.token()) }
267
+ .addOnFailureListener { e ->
268
+ promise.reject("EINTEGRITY", e.message ?: "Play Integrity request failed", e)
269
+ }
270
+ }
271
+
272
+ // Device metadata for Meta CAPI `extinfo` (Task 3n). Synchronous — every
273
+ // API used here is a cheap local read (no network, no blocking I/O beyond
274
+ // a local StatFs stat call). Best-effort per field: a field is simply
275
+ // omitted (not defaulted) when its source is unavailable, so the JS-side
276
+ // fail-safe (collectDeviceInfo) degrades gracefully rather than sending
277
+ // misleading zeros.
278
+ Function("getDeviceInfo") {
279
+ val map = mutableMapOf<String, Any?>()
280
+ map["model"] = android.os.Build.MODEL
281
+ map["locale"] = java.util.Locale.getDefault().toString()
282
+ map["timezone"] = java.util.TimeZone.getDefault().id
283
+ map["cpuCores"] = Runtime.getRuntime().availableProcessors()
284
+
285
+ val ctx = appContext.reactContext?.applicationContext
286
+ if (ctx != null) {
287
+ try {
288
+ val dm = ctx.resources.displayMetrics
289
+ map["screenWidth"] = dm.widthPixels
290
+ map["screenHeight"] = dm.heightPixels
291
+ map["screenDensity"] = dm.density.toDouble()
292
+ } catch (_: Exception) {
293
+ // screen metrics best-effort
294
+ }
295
+ try {
296
+ val statFs = android.os.StatFs(ctx.filesDir.path)
297
+ val blockSize = statFs.blockSizeLong
298
+ val gb = 1024.0 * 1024.0 * 1024.0
299
+ map["totalStorageGb"] = (statFs.blockCountLong * blockSize).toDouble() / gb
300
+ map["freeStorageGb"] = (statFs.availableBlocksLong * blockSize).toDouble() / gb
301
+ } catch (_: Exception) {
302
+ // storage stat best-effort
303
+ }
304
+ try {
305
+ val tm = ctx.getSystemService(android.content.Context.TELEPHONY_SERVICE)
306
+ as? android.telephony.TelephonyManager
307
+ val carrier = tm?.networkOperatorName
308
+ if (!carrier.isNullOrEmpty()) map["carrier"] = carrier
309
+ } catch (_: Exception) {
310
+ // carrier best-effort
311
+ }
312
+ }
313
+ map
314
+ }
315
+
316
+ // Built-in SDK storage (Adjust-style: the app supplies nothing). A
317
+ // dedicated SharedPreferences file keeps SDK keys out of the app's prefs.
318
+ // Wiped on uninstall — Android has no reinstall-durable store, so a
319
+ // reinstall legitimately re-fires the install (backend window dedups).
320
+ Function("storageGetItem") { key: String ->
321
+ sdkPrefs()?.getString(key, null)
322
+ }
323
+ Function("storageSetItem") { key: String, value: String ->
324
+ sdkPrefs()?.edit()?.putString(key, value)?.apply()
325
+ }
326
+ Function("storageRemoveItem") { key: String ->
327
+ sdkPrefs()?.edit()?.remove(key)?.apply()
328
+ }
329
+
330
+ // SSAID (ANDROID_ID): signing-key-scoped, stable across reinstalls, reset
331
+ // only by factory reset. Read by the JS facade ONLY when no usable GAID
332
+ // exists, as a first-party fraud/dedup + reinstall-detection signal (Play
333
+ // policy: never linked to the advertising id, never used for ads).
334
+ Function("getAndroidId") { ->
335
+ try {
336
+ val ctx = appContext.reactContext?.applicationContext
337
+ if (ctx != null) {
338
+ android.provider.Settings.Secure.getString(
339
+ ctx.contentResolver,
340
+ android.provider.Settings.Secure.ANDROID_ID,
341
+ )
342
+ } else {
343
+ null
344
+ }
345
+ } catch (_: Exception) {
346
+ null // best-effort: a missing SSAID never blocks anything
347
+ }
348
+ }
349
+ }
350
+
351
+ private fun sdkPrefs(): android.content.SharedPreferences? =
352
+ appContext.reactContext?.applicationContext
353
+ ?.getSharedPreferences("io.advenue.sdk", android.content.Context.MODE_PRIVATE)
354
+ }