@advenue/react-native 0.8.0 → 1.0.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.
Files changed (94) hide show
  1. package/README.md +8 -7
  2. package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +118 -347
  3. package/android/src/main/kotlin/io/advenue/Advenue.kt +549 -0
  4. package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +114 -0
  5. package/android/src/main/kotlin/io/advenue/core/Backoff.kt +28 -0
  6. package/android/src/main/kotlin/io/advenue/core/ClientEvent.kt +136 -0
  7. package/android/src/main/kotlin/io/advenue/core/CommandPipe.kt +186 -0
  8. package/android/src/main/kotlin/io/advenue/core/Consent.kt +52 -0
  9. package/android/src/main/kotlin/io/advenue/core/Contracts.kt +80 -0
  10. package/android/src/main/kotlin/io/advenue/core/Conversion.kt +69 -0
  11. package/android/src/main/kotlin/io/advenue/core/Engine.kt +414 -0
  12. package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +89 -0
  13. package/android/src/main/kotlin/io/advenue/core/HmacSigner.kt +31 -0
  14. package/android/src/main/kotlin/io/advenue/core/InstallReferrer.kt +146 -0
  15. package/android/src/main/kotlin/io/advenue/core/Json.kt +272 -0
  16. package/android/src/main/kotlin/io/advenue/core/Limits.kt +47 -0
  17. package/android/src/main/kotlin/io/advenue/core/MetaReferrer.kt +80 -0
  18. package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +189 -0
  19. package/android/src/main/kotlin/io/advenue/core/SystemServices.kt +58 -0
  20. package/android/src/main/kotlin/io/advenue/core/Tcf.kt +48 -0
  21. package/android/src/main/kotlin/io/advenue/core/Time.kt +56 -0
  22. package/android/src/main/kotlin/io/advenue/platform/Collectors.kt +149 -0
  23. package/android/src/main/kotlin/io/advenue/platform/CompositeStore.kt +33 -0
  24. package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +68 -0
  25. package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +90 -0
  26. package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +101 -0
  27. package/android/src/main/kotlin/io/advenue/platform/Identity.kt +79 -0
  28. package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +197 -0
  29. package/android/src/main/kotlin/io/advenue/platform/InstallScopedStore.kt +91 -0
  30. package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +72 -0
  31. package/android/src/main/kotlin/io/advenue/platform/PreferencesStore.kt +32 -0
  32. package/android/src/main/kotlin/io/advenue/plugin/Contracts.kt +67 -0
  33. package/android/src/main/kotlin/io/advenue/plugin/FirebaseAppInstanceIdSource.kt +73 -0
  34. package/android/src/main/kotlin/io/advenue/plugin/PlayAdvertisingIdSource.kt +47 -0
  35. package/android/src/main/kotlin/io/advenue/plugin/PlayInstallReferrerSource.kt +85 -0
  36. package/android/src/main/kotlin/io/advenue/plugin/PlayIntegritySource.kt +86 -0
  37. package/android/src/main/kotlin/io/advenue/plugin/PluginRegistry.kt +61 -0
  38. package/dist/index.cjs +183 -655
  39. package/dist/index.d.cts +199 -331
  40. package/dist/index.d.ts +199 -331
  41. package/dist/index.js +182 -661
  42. package/ios/AdvenueIosModule.swift +144 -432
  43. package/ios/vendor/Advenue/Advenue.swift +596 -0
  44. package/ios/vendor/Advenue/AdvenueConfig.swift +77 -0
  45. package/ios/vendor/AdvenueCore/AdvenueValue.swift +103 -0
  46. package/ios/vendor/AdvenueCore/Attestation.swift +52 -0
  47. package/ios/vendor/AdvenueCore/Backoff.swift +28 -0
  48. package/ios/vendor/AdvenueCore/ClientEvent.swift +154 -0
  49. package/ios/vendor/AdvenueCore/Consent.swift +40 -0
  50. package/ios/vendor/AdvenueCore/Contracts.swift +59 -0
  51. package/ios/vendor/AdvenueCore/Conversion.swift +74 -0
  52. package/ios/vendor/AdvenueCore/ConversionValue.swift +217 -0
  53. package/ios/vendor/AdvenueCore/Engine.swift +587 -0
  54. package/ios/vendor/AdvenueCore/EventQueue.swift +89 -0
  55. package/ios/vendor/AdvenueCore/Limits.swift +41 -0
  56. package/ios/vendor/AdvenueCore/PIIScrub.swift +102 -0
  57. package/ios/vendor/AdvenueCore/SessionTracker.swift +139 -0
  58. package/ios/vendor/AdvenueCore/SkanConfig.swift +76 -0
  59. package/ios/vendor/AdvenueCore/SkanReporter.swift +25 -0
  60. package/ios/vendor/AdvenueCore/SkanState.swift +258 -0
  61. package/ios/vendor/AdvenueCore/Tcf.swift +48 -0
  62. package/ios/vendor/AdvenueCore/Transport.swift +14 -0
  63. package/ios/vendor/AdvenueFirebase/FirebaseAppInstanceId.swift +33 -0
  64. package/ios/vendor/AdvenuePlatform/AdvertisingIdentity.swift +87 -0
  65. package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +42 -0
  66. package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +63 -0
  67. package/ios/vendor/AdvenuePlatform/CryptoKitSigner.swift +19 -0
  68. package/ios/vendor/AdvenuePlatform/DeviceCheckAttestation.swift +91 -0
  69. package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +65 -0
  70. package/ios/vendor/AdvenuePlatform/ForegroundTracker.swift +55 -0
  71. package/ios/vendor/AdvenuePlatform/HttpTransport.swift +96 -0
  72. package/ios/vendor/AdvenuePlatform/Identity.swift +52 -0
  73. package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +143 -0
  74. package/ios/vendor/AdvenuePlatform/KeychainStore.swift +95 -0
  75. package/ios/vendor/AdvenuePlatform/SearchAdsToken.swift +76 -0
  76. package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +70 -0
  77. package/ios/vendor/AdvenuePlatform/StoreKitSkanReporter.swift +140 -0
  78. package/ios/vendor/AdvenuePlatform/SystemServices.swift +55 -0
  79. package/ios/vendor/AdvenuePlatform/TcfReader.swift +18 -0
  80. package/ios/vendor/AdvenuePlatform/UserDefaultsStore.swift +26 -0
  81. package/package.json +9 -11
  82. package/scripts/check-dist.mjs +17 -0
  83. package/scripts/check-vendored-swift.mjs +148 -0
  84. package/scripts/vendor-natives.mjs +149 -0
  85. package/scripts/vendor-natives.test.mjs +112 -0
  86. package/src/deep-links.ts +19 -1
  87. package/src/index.ts +267 -803
  88. package/src/native-types.ts +74 -171
  89. package/src/native.ts +0 -23
  90. package/src/types.ts +81 -0
  91. package/src/aem.ts +0 -33
  92. package/src/mmkv-storage.ts +0 -21
  93. package/src/native-storage.ts +0 -63
  94. package/src/secure-store.ts +0 -26
@@ -0,0 +1,414 @@
1
+ package io.advenue.core
2
+
3
+ /**
4
+ * The three install-scoped keys. All of them die with the app: Android has no
5
+ * reinstall-durable store, so a reinstall is legitimately a new install and the
6
+ * Keychain asymmetry the iOS SDK relies on does not exist here.
7
+ */
8
+ internal const val DEVICE_ID_KEY: String = "advenue.device_id"
9
+
10
+ internal const val INSTALLATION_ID_KEY: String = "advenue.installation_id"
11
+
12
+ internal const val INSTALL_SENT_KEY: String = "advenue.install_sent"
13
+ /**
14
+ * The server rejects a batch of more than this: `eventBatchSchema` in
15
+ * `packages/shared/src/events.ts` is
16
+ * `z.array(clientEventSchema).min(1).max(100)`.
17
+ *
18
+ * It lives in the core rather than beside the transport because it is a fact
19
+ * about the wire, not about any one way of reaching it — and because the engine
20
+ * has to clamp to it, which the transport cannot do from where it sits.
21
+ */
22
+ internal const val MAX_BATCH_SIZE: Int = 100
23
+
24
+ internal const val CONSENT_KEY: String = "advenue.consent"
25
+ internal const val CONSENT_DATA_KEY: String = "advenue.consent_data"
26
+ internal const val USER_ID_KEY: String = "advenue.user_id"
27
+
28
+ internal data class EngineConfig(
29
+ val apiKey: String,
30
+ val platform: String = "android",
31
+ val deviceId: String,
32
+ val installationId: String? = null,
33
+ val appVersion: String? = null,
34
+ val osVersion: String? = null,
35
+ val sdkVersion: String? = null,
36
+ val requireConsent: Boolean = false,
37
+ val sessionWindowMs: Long = DEFAULT_SESSION_WINDOW_MS,
38
+ val maxQueueSize: Int = 10_000,
39
+ val batchSize: Int = 20,
40
+ val retryBaseMs: Double = 1_000.0,
41
+ val retryCapMs: Double = 60_000.0,
42
+ )
43
+
44
+ /**
45
+ * Owns every piece of mutable SDK state.
46
+ *
47
+ * **Confinement, not locking.** Commands arrive through [CommandPipe] and are
48
+ * handled one at a time on a single thread, which reproduces sdk-core's
49
+ * single-threaded semantics — the property that makes the conformance vectors
50
+ * meaningful in the first place. This class takes no locks deliberately: a lock
51
+ * here would hide a confinement violation rather than prevent one.
52
+ */
53
+ internal class AdvenueEngine(
54
+ private val config: EngineConfig,
55
+ private val store: KeyValueStore,
56
+ private val clock: Clock,
57
+ scheduler: Scheduler,
58
+ private val uuid: UuidSource,
59
+ private val transport: EventTransport? = null,
60
+ private val onDrop: (List<ClientEvent>) -> Unit = {},
61
+ private val random: () -> Double = { Math.random() },
62
+ private val onError: (String, Throwable) -> Unit = { _, _ -> },
63
+ ) {
64
+ private val queue = EventQueue(store, config.maxQueueSize, scheduler)
65
+ private val sessions =
66
+ SessionTracker(store, clock, config.sessionWindowMs, uuid)
67
+
68
+ private var consent: Boolean = store.getString(CONSENT_KEY) == "granted"
69
+ private var forgotten = false
70
+ private var customerUserId: String? = store.getString(USER_ID_KEY)
71
+ private var idfa: String? = null
72
+ private var gaid: String? = null
73
+ private var androidId: String? = null
74
+ private var appInstanceId: String? = null
75
+ private var consentData: Consent? = readConsentData()
76
+ private var pushToken: String? = null
77
+ private var pushProvider: String? = null
78
+ private var flushing = false
79
+ private var consecutiveFailures = 0
80
+ private var backoffUntilMs = 0L
81
+
82
+ /** Enqueues an event, or refuses it and says why. Mirrors sdk-core's `track()`. */
83
+ public fun track(
84
+ name: String,
85
+ properties: Map<String, Any?>? = null,
86
+ type: String = "custom",
87
+ ): Boolean {
88
+ if (forgotten || (config.requireConsent && !consent)) return false
89
+ checkTrackInput(name, properties)?.let { rejection ->
90
+ onError("track.rejected:$rejection", IngestError(400))
91
+ return false
92
+ }
93
+ queue.enqueue(newEvent(type = type, name = name, properties = properties))
94
+ return true
95
+ }
96
+
97
+ public fun setConsent(granted: Boolean) {
98
+ consent = granted
99
+ store.setString(CONSENT_KEY, if (granted) "granted" else "denied")
100
+ }
101
+
102
+ /**
103
+ * Records granular ad-platform consent. Last write wins, and it is persisted,
104
+ * so it survives restarts — a stated preference silently reverting on
105
+ * relaunch is the failure this guards.
106
+ */
107
+ internal fun setConsentData(consent: Consent?) {
108
+ if (forgotten) return
109
+ consentData = consent
110
+ if (consent == null) store.remove(CONSENT_DATA_KEY)
111
+ else store.setString(CONSENT_DATA_KEY, writeCanonicalJson(consent.toMap()))
112
+ }
113
+
114
+ internal fun getConsentData(): Consent? = consentData
115
+
116
+ internal fun getConsent(): Boolean = consent
117
+
118
+ /**
119
+ * Registers the device's push token for uninstall measurement (#26).
120
+ *
121
+ * The host app owns push registration: the SDK never asks for the
122
+ * notification permission and never displays anything. Pass the token your
123
+ * push library already gives you, on every launch — the OS can rotate it at
124
+ * any time, and a stale token is the one thing that makes uninstall
125
+ * measurement report churn that did not happen.
126
+ */
127
+ internal fun setPushToken(token: String?, provider: String? = null) {
128
+ if (forgotten) return
129
+ if (token == null) {
130
+ pushToken = null
131
+ pushProvider = null
132
+ return
133
+ }
134
+ val trimmed = token.trim()
135
+ if (!isValidPushToken(trimmed)) {
136
+ pushToken = null
137
+ pushProvider = null
138
+ onError("push.setPushToken", IngestError(400))
139
+ return
140
+ }
141
+ pushToken = trimmed
142
+ pushProvider = provider ?: if (config.platform == "android") "fcm" else "apns"
143
+ }
144
+
145
+ private fun readConsentData(): Consent? =
146
+ (parseJson(store.getString(CONSENT_DATA_KEY) ?: "") as? Map<*, *>)?.let(Consent::fromMap)
147
+
148
+ public fun setUserId(id: String?) {
149
+ customerUserId = id
150
+ if (id != null) store.setString(USER_ID_KEY, id) else store.remove(USER_ID_KEY)
151
+ }
152
+
153
+ /**
154
+ * Identity attached to every subsequent event. Not persisted: it is
155
+ * re-resolved each launch, because the advertising id and its limit flag can
156
+ * both change between them.
157
+ */
158
+ public fun setIdentity(
159
+ gaid: String?,
160
+ androidId: String?,
161
+ appInstanceId: String?,
162
+ idfa: String? = null,
163
+ ) {
164
+ this.gaid = gaid
165
+ this.androidId = androidId
166
+ this.appInstanceId = appInstanceId
167
+ this.idfa = idfa
168
+ }
169
+
170
+ /**
171
+ * The Firebase App Instance ID alone. Separate from [setIdentity] because it
172
+ * arrives on its own schedule — an app can set it at any point, and folding
173
+ * it into the multi-field setter would clear the advertising identity the
174
+ * install enrichment resolved.
175
+ */
176
+ public fun setAppInstanceId(id: String?) {
177
+ this.appInstanceId = id
178
+ }
179
+
180
+ /**
181
+ * The first-open event, at most once per installation.
182
+ *
183
+ * Ordering is deliberate. The consent gate comes first, so a refused install
184
+ * leaves no flag and can still fire once consent arrives. The flag is written
185
+ * last, so a crash between the event and the flag costs a duplicate the
186
+ * backend dedup window absorbs — a permanently missing install is the worse
187
+ * failure.
188
+ */
189
+ public fun trackInstall(properties: Map<String, Any?>? = null): Boolean {
190
+ if (forgotten || (config.requireConsent && !consent)) return false
191
+ if (store.getString(INSTALL_SENT_KEY) == "1") return false
192
+ queue.enqueue(newEvent(type = "install", name = "install", properties = properties))
193
+ store.setString(INSTALL_SENT_KEY, "1")
194
+ return true
195
+ }
196
+
197
+ /**
198
+ * Erasure, including the install-scoped identifiers.
199
+ *
200
+ * They are wiped HERE rather than by the facade, and the ordering is the
201
+ * reason. The facade's caller runs on its own thread while the pipe may still
202
+ * be holding a queued `trackInstall`; a wipe performed outside the pipe races
203
+ * that command and the flag is written back after the erasure. Routing it
204
+ * through the same ordered ingress puts the wipe behind everything already
205
+ * submitted.
206
+ *
207
+ * The store routes these three keys to `noBackupFilesDir` — see
208
+ * `CompositeStore`. Clearing only the preferences half is the layering trap
209
+ * the spec recorded: an erasure that leaves the identifiers behind and looks
210
+ * finished.
211
+ */
212
+ public fun forgetMe() {
213
+ forgotten = true
214
+ queue.clear()
215
+ sessions.reset()
216
+ consent = false
217
+ consentData = null
218
+ pushToken = null
219
+ pushProvider = null
220
+ customerUserId = null
221
+ idfa = null
222
+ gaid = null
223
+ androidId = null
224
+ appInstanceId = null
225
+ for (key in
226
+ listOf(
227
+ CONSENT_KEY,
228
+ CONSENT_DATA_KEY,
229
+ SESSION_STATE_KEY,
230
+ USER_ID_KEY,
231
+ DEVICE_ID_KEY,
232
+ INSTALLATION_ID_KEY,
233
+ INSTALL_SENT_KEY,
234
+ )) {
235
+ store.remove(key)
236
+ }
237
+ }
238
+
239
+ public fun notifyForeground() {
240
+ if (forgotten || (config.requireConsent && !consent)) return
241
+ sessions.handleForeground().forEach { track(it.name, it.properties, type = "session") }
242
+ }
243
+
244
+ public fun notifyBackground() {
245
+ if (forgotten || (config.requireConsent && !consent)) return
246
+ sessions.handleBackground()?.let { track(it.name, it.properties, type = "session") }
247
+ }
248
+
249
+ public fun pendingEventIds(): List<String> = queue.peek(Int.MAX_VALUE).map { it.id }
250
+
251
+ /** Test surface: the ids alone cannot say what a field carries. */
252
+ public fun pendingEvents(): List<ClientEvent> = queue.peek(Int.MAX_VALUE)
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Flush.
256
+ //
257
+ // The DECISION logic below runs on the engine's thread and nowhere else; the
258
+ // network call is the only part that may run elsewhere. That split is why the
259
+ // three steps are separate methods rather than one loop: `CommandPipe` drives
260
+ // them across two threads so a stalled request cannot block the commands
261
+ // queued behind it, while `flush()` drives the same three methods inline for
262
+ // callers and for the vectors. One implementation of the decisions, two
263
+ // drivers — a second copy is exactly the drift the vectors exist to prevent.
264
+ // ---------------------------------------------------------------------------
265
+
266
+ private var inFlight: List<ClientEvent>? = null
267
+ private var isolationCursor: MutableList<ClientEvent>? = null
268
+
269
+ /** Whether a transport is wired at all. */
270
+ internal fun canFlush(): Boolean = transport != null
271
+
272
+ /** Decides what to send. Null means there is nothing to do. */
273
+ internal fun beginFlush(): List<ClientEvent>? {
274
+ if (transport == null || flushing || queue.size == 0 || clock.nowMs() < backoffUntilMs) {
275
+ return null
276
+ }
277
+ // Clamped to the wire's limit, not trusted. The server answers 400 for a
278
+ // larger batch and a 400 is not retryable, so an app that set 200 would
279
+ // have every batch rejected and then re-sent one event at a time by the
280
+ // poison-isolation path: nothing lost, and every flush costing 1 + N
281
+ // requests forever. The constant said 100 and enforced nothing until now.
282
+ val events = queue.peek(minOf(config.batchSize, MAX_BATCH_SIZE))
283
+ if (events.isEmpty()) return null
284
+ flushing = true
285
+ inFlight = events
286
+ return events
287
+ }
288
+
289
+ /**
290
+ * Consumes the outcome of the batch send. Returns the first event to isolate,
291
+ * or null when the flush is finished.
292
+ */
293
+ internal fun onBatchOutcome(error: Throwable?): ClientEvent? {
294
+ val events = inFlight ?: return null
295
+ if (error == null) {
296
+ queue.ack(events)
297
+ consecutiveFailures = 0
298
+ backoffUntilMs = 0L
299
+ endFlush()
300
+ return null
301
+ }
302
+
303
+ val ingest = error as? IngestError
304
+ if (ingest != null && !ingest.isRetryable) {
305
+ // Poison payload. Ingest parses a batch as a whole and answers one 400 for
306
+ // all of it, so the offender has to be found rather than the batch
307
+ // discarded. A 4xx is not an outage, so it does not arm the backoff.
308
+ onError("flush.poison", error)
309
+ val cursor = events.toMutableList()
310
+ isolationCursor = cursor
311
+ return cursor.removeAt(0)
312
+ }
313
+
314
+ // Transient (network, 5xx, 429): keep the batch and back off, so a fleet
315
+ // recovering from an outage does not retry in lockstep.
316
+ onError("flush.transport", error)
317
+ armBackoff()
318
+ endFlush()
319
+ return null
320
+ }
321
+
322
+ /**
323
+ * Consumes the outcome of one isolated event and returns the next, or null
324
+ * when isolation is over. Stops at the first transient error, so a network
325
+ * drop mid-isolation cannot turn deliverable events into dropped ones.
326
+ */
327
+ internal fun onSingleOutcome(event: ClientEvent, error: Throwable?): ClientEvent? {
328
+ if (error == null) {
329
+ queue.ack(listOf(event))
330
+ } else {
331
+ val ingest = error as? IngestError
332
+ if (ingest == null || ingest.isRetryable) {
333
+ endFlush()
334
+ return null
335
+ }
336
+ queue.ack(listOf(event))
337
+ onDrop(listOf(event))
338
+ }
339
+
340
+ val cursor = isolationCursor
341
+ if (cursor == null || cursor.isEmpty()) {
342
+ endFlush()
343
+ return null
344
+ }
345
+ return cursor.removeAt(0)
346
+ }
347
+
348
+ private fun endFlush() {
349
+ flushing = false
350
+ inFlight = null
351
+ isolationCursor = null
352
+ }
353
+
354
+ /**
355
+ * Inline driver: sends on the calling thread. Used by callers without a pipe
356
+ * and by the conformance vectors. Never throws — a timer-driven call is not
357
+ * awaited, so a transient failure simply leaves the batch buffered.
358
+ */
359
+ public fun flush() {
360
+ val batch = beginFlush() ?: return
361
+ var next = onBatchOutcome(send(batch))
362
+ while (next != null) {
363
+ next = onSingleOutcome(next, send(listOf(next)))
364
+ }
365
+ }
366
+
367
+ /** The single network call, returning its failure rather than throwing. */
368
+ internal fun send(events: List<ClientEvent>): Throwable? =
369
+ try {
370
+ transport?.send(events)
371
+ null
372
+ } catch (error: Throwable) {
373
+ error
374
+ }
375
+
376
+ private fun armBackoff() {
377
+ consecutiveFailures += 1
378
+ val delay =
379
+ backoffDelayMs(consecutiveFailures, config.retryBaseMs, config.retryCapMs, random())
380
+ backoffUntilMs = clock.nowMs() + delay.toLong()
381
+ }
382
+
383
+ /** Test surface: the `flush/` vectors assert that a 4xx leaves this at zero. */
384
+ public fun consecutiveFailureCount(): Int = consecutiveFailures
385
+
386
+ private fun newEvent(
387
+ type: String,
388
+ name: String,
389
+ properties: Map<String, Any?>?,
390
+ ): ClientEvent =
391
+ ClientEvent(
392
+ id = uuid.next(),
393
+ deviceId = config.deviceId,
394
+ type = type,
395
+ name = name,
396
+ timestamp = iso8601(clock.nowMs()),
397
+ platform = config.platform,
398
+ installationId = config.installationId,
399
+ appVersion = config.appVersion,
400
+ osVersion = config.osVersion,
401
+ sdkVersion = config.sdkVersion,
402
+ customerUserId = customerUserId,
403
+ idfa = idfa,
404
+ gaid = gaid,
405
+ androidId = androidId,
406
+ appInstanceId = appInstanceId,
407
+ consent = consentData?.toMap(),
408
+ // Lifecycle events only. A push token is ~180 bytes and the registry
409
+ // needs it periodically, not on every custom event in a 100-event batch.
410
+ pushToken = if (type == "install" || type == "session") pushToken else null,
411
+ pushProvider = if (type == "install" || type == "session") pushProvider else null,
412
+ properties = properties,
413
+ )
414
+ }
@@ -0,0 +1,89 @@
1
+ package io.advenue.core
2
+
3
+ internal const val QUEUE_KEY: String = "advenue.queue"
4
+
5
+ /** Persist debounce window. Bursts inside it coalesce into one write. */
6
+ internal const val PERSIST_DEBOUNCE_MS: Int = 100
7
+
8
+ /**
9
+ * Durable FIFO event buffer, mirroring sdk-core's `EventQueue` including its
10
+ * persisted blob shape — the RN inversion has to read what TypeScript wrote.
11
+ *
12
+ * Persist strategy: `enqueue` debounces so a burst costs one write; `ack` and
13
+ * `clear` are flush boundaries and write synchronously, because stale storage
14
+ * at those points means event loss on ack or a double send after a crash.
15
+ *
16
+ * Confined to the engine's single thread. Not thread-safe, and deliberately so:
17
+ * a lock here would hide a confinement mistake rather than prevent one.
18
+ */
19
+ internal class EventQueue(
20
+ private val store: KeyValueStore,
21
+ private val maxSize: Int = 10_000,
22
+ private val scheduler: Scheduler,
23
+ ) {
24
+ private val events: MutableList<ClientEvent> = load(store).toMutableList()
25
+ private var persistToken: CancelToken? = null
26
+
27
+ public val size: Int
28
+ get() = events.size
29
+
30
+ public fun enqueue(event: ClientEvent) {
31
+ events.add(event)
32
+ // Bound the buffer: a long offline period on a chatty app must not grow the
33
+ // persisted blob without limit. Recent events are the ones worth keeping.
34
+ while (events.size > maxSize) events.removeAt(0)
35
+ schedulePersist()
36
+ }
37
+
38
+ public fun peek(max: Int): List<ClientEvent> = events.take(max)
39
+
40
+ public fun ack(sent: List<ClientEvent>) {
41
+ if (sent.isEmpty()) return
42
+ val ids = sent.map { it.id }.toHashSet()
43
+ events.removeAll { it.id in ids }
44
+ forcePersist()
45
+ }
46
+
47
+ public fun clear() {
48
+ events.clear()
49
+ forcePersist()
50
+ }
51
+
52
+ private fun schedulePersist() {
53
+ if (persistToken != null) return
54
+ persistToken =
55
+ scheduler.schedule(PERSIST_DEBOUNCE_MS) {
56
+ persistToken = null
57
+ write()
58
+ }
59
+ }
60
+
61
+ private fun forcePersist() {
62
+ persistToken?.let {
63
+ scheduler.cancel(it)
64
+ persistToken = null
65
+ }
66
+ write()
67
+ }
68
+
69
+ private fun write() {
70
+ try {
71
+ store.setString(QUEUE_KEY, writeCanonicalJson(events.map { it.toMap() }))
72
+ } catch (_: Exception) {
73
+ // A failed write costs the offline buffer, not the process. The in-memory
74
+ // list stays authoritative for this launch.
75
+ }
76
+ }
77
+
78
+ private companion object {
79
+ /**
80
+ * A corrupt blob loads as empty rather than throwing: bricking the SDK on
81
+ * every launch is worse than losing an offline buffer.
82
+ */
83
+ private fun load(store: KeyValueStore): List<ClientEvent> {
84
+ val raw = store.getString(QUEUE_KEY) ?: return emptyList()
85
+ val parsed = parseJson(raw) as? List<*> ?: return emptyList()
86
+ return parsed.mapNotNull { (it as? Map<*, *>)?.let(ClientEvent::fromMap) }
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,31 @@
1
+ package io.advenue.core
2
+
3
+ import javax.crypto.Mac
4
+ import javax.crypto.spec.SecretKeySpec
5
+
6
+ /**
7
+ * Production signing. `javax.crypto` is part of every JDK and every Android
8
+ * runtime, so unlike the Swift port — where `AdvenueCore` had to stay free of a
9
+ * crypto library to build on Linux — the core can own this outright. One HMAC
10
+ * implementation, not two.
11
+ */
12
+ internal class JvmHmacSigner : Signer {
13
+ override fun hmacSha256Hex(secret: String, message: String): String {
14
+ val mac = Mac.getInstance("HmacSHA256")
15
+ mac.init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256"))
16
+ // UTF-8, not UTF-16: TextEncoder in the TypeScript original encodes UTF-8,
17
+ // and a port hashing UTF-16 code units would agree on every ASCII body and
18
+ // diverge on exactly the ones with emoji in them.
19
+ val digest = mac.doFinal(message.toByteArray(Charsets.UTF_8))
20
+ val out = StringBuilder(digest.size * 2)
21
+ for (byte in digest) {
22
+ out.append(HEX[(byte.toInt() shr 4) and 0xF])
23
+ out.append(HEX[byte.toInt() and 0xF])
24
+ }
25
+ return out.toString()
26
+ }
27
+
28
+ private companion object {
29
+ private val HEX = "0123456789abcdef".toCharArray()
30
+ }
31
+ }
@@ -0,0 +1,146 @@
1
+ package io.advenue.core
2
+
3
+ import java.net.URLDecoder
4
+
5
+ /**
6
+ * A parsed Play Install Referrer. Ported field for field from
7
+ * `packages/install-referrer/src/index.ts`, which is the authority — the server
8
+ * attributes on these names.
9
+ */
10
+ internal data class ParsedReferrer(
11
+ val network: String?,
12
+ val source: String?,
13
+ val medium: String?,
14
+ val campaign: String?,
15
+ val content: String?,
16
+ val term: String?,
17
+ /** Google click id (paid Google/UAC). */
18
+ val gclid: String?,
19
+ /** Meta/Facebook click id. */
20
+ val fbclid: String?,
21
+ /** Full decoded key/value map, for anything non-standard. */
22
+ val params: Map<String, String>,
23
+ val raw: String,
24
+ )
25
+
26
+ private val SOURCE_NETWORK =
27
+ mapOf(
28
+ "google" to "google",
29
+ "google-play" to "google",
30
+ "googleads" to "google",
31
+ "google-ads" to "google",
32
+ "adwords" to "google",
33
+ "uac" to "google",
34
+ "admob" to "admob",
35
+ "facebook" to "meta",
36
+ "facebook-ads" to "meta",
37
+ "fb" to "meta",
38
+ "instagram" to "meta",
39
+ "ig" to "meta",
40
+ "meta" to "meta",
41
+ "meta-ads" to "meta",
42
+ "tiktok" to "tiktok",
43
+ "tiktok-ads" to "tiktok",
44
+ "tiktokads" to "tiktok",
45
+ "apple-search-ads" to "apple",
46
+ "apple_search_ads" to "apple",
47
+ "apple-ads" to "apple",
48
+ "asa" to "apple",
49
+ "snapchat" to "snapchat",
50
+ "x" to "x",
51
+ "twitter" to "x",
52
+ "youtube" to "youtube",
53
+ )
54
+
55
+ /**
56
+ * Parses a Play Install Referrer string (or any utm-tagged referrer) into
57
+ * normalised attribution params.
58
+ */
59
+ internal fun parseInstallReferrer(raw: String): ParsedReferrer {
60
+ // A fully percent-encoded referrer has no literal '='; decode it once first.
61
+ // An already-decoded referrer keeps its '=' and is decoded per key/value
62
+ // below — decoding the whole string would turn an encoded '&' or '=' inside a
63
+ // value into a false separator.
64
+ val decoded = if (raw.contains('=')) raw else safeDecode(raw)
65
+
66
+ val params = LinkedHashMap<String, String>()
67
+ for (pair in decoded.split('&')) {
68
+ if (pair.isEmpty()) continue
69
+ val eq = pair.indexOf('=')
70
+ val key = if (eq >= 0) pair.substring(0, eq) else pair
71
+ val value = if (eq >= 0) pair.substring(eq + 1) else ""
72
+ if (key.isNotEmpty()) params[safeDecode(key)] = safeDecode(value.replace("+", " "))
73
+ }
74
+
75
+ return ParsedReferrer(
76
+ network = inferNetwork(params),
77
+ source = params["utm_source"],
78
+ medium = params["utm_medium"],
79
+ campaign = params["utm_campaign"] ?: params["campaign"],
80
+ content = params["utm_content"],
81
+ term = params["utm_term"],
82
+ gclid = params["gclid"],
83
+ fbclid = params["fbclid"],
84
+ params = params,
85
+ raw = raw,
86
+ )
87
+ }
88
+
89
+ private fun inferNetwork(params: Map<String, String>): String? {
90
+ if (params["gclid"] != null) return "google"
91
+ val source = params["utm_source"]?.trim()?.lowercase()
92
+ if (source.isNullOrEmpty()) return null
93
+
94
+ // The Play Store's DEFAULT referrer for an organic install is
95
+ // "utm_source=google-play&utm_medium=organic" — the store itself, not Google
96
+ // Ads. Without a paid click id (gclid, above) or a paid medium these MUST
97
+ // stay organic, or every organic Android install is credited to paid Google.
98
+ // That is a financial misattribution, not a reporting nuance.
99
+ val medium = params["utm_medium"]?.trim()?.lowercase()
100
+ if (medium == "organic") return null
101
+ if ((source == "google-play" || source == "com.android.vending") && medium.isNullOrEmpty()) {
102
+ return null
103
+ }
104
+
105
+ // Exact token match against known networks; anything else passes through as
106
+ // its own lower-cased key. An unknown utm_source is a real source, it just is
107
+ // not one we can canonicalise.
108
+ return SOURCE_NETWORK[source] ?: source
109
+ }
110
+
111
+ /**
112
+ * The referrer fields that ride the install event.
113
+ *
114
+ * Device-clock values always travel under the client-trust keys. The
115
+ * server-trusted keys are populated **only** from Play's genuine server-clock
116
+ * getters and only when non-zero, because Play returns 0 for an unverified
117
+ * install. Aliasing a device clock into a server key would hand the fraud
118
+ * scorer a trust signal the device could forge.
119
+ */
120
+ internal fun referrerProperties(
121
+ referrer: String,
122
+ clickTimestamp: Long,
123
+ installTimestamp: Long,
124
+ clickServerTimestamp: Long,
125
+ installServerTimestamp: Long,
126
+ googlePlayInstant: Boolean,
127
+ ): Map<String, Any?> {
128
+ val props =
129
+ linkedMapOf<String, Any?>(
130
+ "installReferrer" to referrer,
131
+ "referrerClickTimestamp" to clickTimestamp,
132
+ "installBeginTimestamp" to installTimestamp,
133
+ "googlePlayInstant" to googlePlayInstant,
134
+ )
135
+ if (clickServerTimestamp != 0L) props["referrerClickServerTimestamp"] = clickServerTimestamp
136
+ if (installServerTimestamp != 0L) props["installBeginServerTimestamp"] = installServerTimestamp
137
+ return props
138
+ }
139
+
140
+ private fun safeDecode(value: String): String =
141
+ try {
142
+ URLDecoder.decode(value, "UTF-8")
143
+ } catch (_: Exception) {
144
+ // A malformed escape must cost the decoding, not the install.
145
+ value
146
+ }