@advenue/react-native 0.9.0 → 1.0.1

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 (96) hide show
  1. package/README.md +8 -7
  2. package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +117 -368
  3. package/android/src/main/kotlin/io/advenue/Advenue.kt +574 -0
  4. package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +141 -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 +193 -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 +436 -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 +50 -0
  17. package/android/src/main/kotlin/io/advenue/core/MetaReferrer.kt +80 -0
  18. package/android/src/main/kotlin/io/advenue/core/PiiScrub.kt +77 -0
  19. package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +189 -0
  20. package/android/src/main/kotlin/io/advenue/core/SystemServices.kt +58 -0
  21. package/android/src/main/kotlin/io/advenue/core/Tcf.kt +48 -0
  22. package/android/src/main/kotlin/io/advenue/core/Time.kt +56 -0
  23. package/android/src/main/kotlin/io/advenue/platform/Collectors.kt +163 -0
  24. package/android/src/main/kotlin/io/advenue/platform/CompositeStore.kt +33 -0
  25. package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +68 -0
  26. package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +90 -0
  27. package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +101 -0
  28. package/android/src/main/kotlin/io/advenue/platform/Identity.kt +79 -0
  29. package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +197 -0
  30. package/android/src/main/kotlin/io/advenue/platform/InstallScopedStore.kt +96 -0
  31. package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +72 -0
  32. package/android/src/main/kotlin/io/advenue/platform/PreferencesStore.kt +32 -0
  33. package/android/src/main/kotlin/io/advenue/plugin/Contracts.kt +67 -0
  34. package/android/src/main/kotlin/io/advenue/plugin/FirebaseAppInstanceIdSource.kt +73 -0
  35. package/android/src/main/kotlin/io/advenue/plugin/PlayAdvertisingIdSource.kt +47 -0
  36. package/android/src/main/kotlin/io/advenue/plugin/PlayInstallReferrerSource.kt +85 -0
  37. package/android/src/main/kotlin/io/advenue/plugin/PlayIntegritySource.kt +86 -0
  38. package/android/src/main/kotlin/io/advenue/plugin/PluginRegistry.kt +61 -0
  39. package/dist/index.cjs +188 -675
  40. package/dist/index.d.cts +203 -336
  41. package/dist/index.d.ts +203 -336
  42. package/dist/index.js +187 -681
  43. package/ios/AdvenueIosModule.swift +143 -446
  44. package/ios/vendor/Advenue/Advenue.swift +632 -0
  45. package/ios/vendor/Advenue/AdvenueConfig.swift +89 -0
  46. package/ios/vendor/AdvenueCore/AdvenueValue.swift +103 -0
  47. package/ios/vendor/AdvenueCore/Attestation.swift +52 -0
  48. package/ios/vendor/AdvenueCore/Backoff.swift +28 -0
  49. package/ios/vendor/AdvenueCore/ClientEvent.swift +154 -0
  50. package/ios/vendor/AdvenueCore/Consent.swift +40 -0
  51. package/ios/vendor/AdvenueCore/Contracts.swift +59 -0
  52. package/ios/vendor/AdvenueCore/Conversion.swift +74 -0
  53. package/ios/vendor/AdvenueCore/ConversionValue.swift +217 -0
  54. package/ios/vendor/AdvenueCore/Engine.swift +607 -0
  55. package/ios/vendor/AdvenueCore/EventQueue.swift +89 -0
  56. package/ios/vendor/AdvenueCore/Limits.swift +41 -0
  57. package/ios/vendor/AdvenueCore/PIIScrub.swift +102 -0
  58. package/ios/vendor/AdvenueCore/SessionTracker.swift +139 -0
  59. package/ios/vendor/AdvenueCore/SkanConfig.swift +76 -0
  60. package/ios/vendor/AdvenueCore/SkanReporter.swift +25 -0
  61. package/ios/vendor/AdvenueCore/SkanState.swift +258 -0
  62. package/ios/vendor/AdvenueCore/Tcf.swift +48 -0
  63. package/ios/vendor/AdvenueCore/Transport.swift +14 -0
  64. package/ios/vendor/AdvenueFirebase/FirebaseAppInstanceId.swift +33 -0
  65. package/ios/vendor/AdvenuePlatform/AdvertisingIdentity.swift +87 -0
  66. package/ios/vendor/AdvenuePlatform/CacheFileStore.swift +90 -0
  67. package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +42 -0
  68. package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +63 -0
  69. package/ios/vendor/AdvenuePlatform/CryptoKitSigner.swift +19 -0
  70. package/ios/vendor/AdvenuePlatform/DeviceCheckAttestation.swift +91 -0
  71. package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +77 -0
  72. package/ios/vendor/AdvenuePlatform/ForegroundTracker.swift +55 -0
  73. package/ios/vendor/AdvenuePlatform/HttpTransport.swift +96 -0
  74. package/ios/vendor/AdvenuePlatform/Identity.swift +52 -0
  75. package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +155 -0
  76. package/ios/vendor/AdvenuePlatform/KeychainStore.swift +95 -0
  77. package/ios/vendor/AdvenuePlatform/SearchAdsToken.swift +76 -0
  78. package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +70 -0
  79. package/ios/vendor/AdvenuePlatform/StoreKitSkanReporter.swift +140 -0
  80. package/ios/vendor/AdvenuePlatform/SystemServices.swift +55 -0
  81. package/ios/vendor/AdvenuePlatform/TcfReader.swift +18 -0
  82. package/ios/vendor/AdvenuePlatform/UserDefaultsStore.swift +26 -0
  83. package/package.json +13 -15
  84. package/scripts/check-dist.mjs +17 -0
  85. package/scripts/check-vendored-swift.mjs +148 -0
  86. package/scripts/vendor-natives.mjs +149 -0
  87. package/scripts/vendor-natives.test.mjs +112 -0
  88. package/src/deep-links.ts +19 -1
  89. package/src/index.ts +273 -830
  90. package/src/native-types.ts +73 -181
  91. package/src/native.ts +0 -23
  92. package/src/types.ts +86 -0
  93. package/src/aem.ts +0 -33
  94. package/src/mmkv-storage.ts +0 -21
  95. package/src/native-storage.ts +0 -63
  96. package/src/secure-store.ts +0 -26
@@ -0,0 +1,69 @@
1
+ package io.advenue.core
2
+
3
+ /**
4
+ * Deferred deep linking: the install that came from a link carrying a
5
+ * destination, where the destination has to survive the trip through the store.
6
+ *
7
+ * The device asks the server what it was attributed to. An organic install is
8
+ * never attributed, so the lookup is *expected* to come back pending forever for
9
+ * most devices — which is why the loop gives up rather than polling on.
10
+ */
11
+ public data class DeepLink(
12
+ val deepLinkValue: String? = null,
13
+ val campaign: String? = null,
14
+ val network: String? = null,
15
+ val influencerId: String? = null,
16
+ val params: Map<String, Any?>? = null,
17
+ val isDeferred: Boolean = true,
18
+ val isFirstLaunch: Boolean = true,
19
+ )
20
+
21
+ /** What a lookup returned: still deciding, decided, or refused. */
22
+ public sealed class ConversionResult {
23
+ public object Pending : ConversionResult()
24
+
25
+ public data class Resolved(val link: DeepLink) : ConversionResult()
26
+ }
27
+
28
+ public interface ConversionFetcher {
29
+ /** Throws [IngestError] on a non-2xx; [IngestError.isRetryable] decides. */
30
+ public fun fetch(): ConversionResult
31
+ }
32
+
33
+ /** Copied from `packages/sdk-core/src/conversion.ts`, not chosen here. */
34
+ public val DEFAULT_CONVERSION_BACKOFF_MS: List<Long> = listOf(500L, 1_000L, 2_000L, 4_000L)
35
+
36
+ /**
37
+ * Polls the conversion lookup until it resolves, the budget runs out, or the
38
+ * server says never.
39
+ *
40
+ * The three exits are deliberately different. A **pending** answer means the
41
+ * pipeline has not decided yet, so it is worth waiting. A **non-retryable**
42
+ * error is the server saying it never will, and retrying it is pure battery
43
+ * cost on a device that will never get an answer. A **transient** error is
44
+ * indistinguishable from pending, so it falls through to the same backoff.
45
+ */
46
+ public fun resolveDeferredDeepLink(
47
+ fetcher: ConversionFetcher,
48
+ maxAttempts: Int = 5,
49
+ backoffMs: List<Long> = DEFAULT_CONVERSION_BACKOFF_MS,
50
+ sleep: (Long) -> Unit = { Thread.sleep(it) },
51
+ ): DeepLink? {
52
+ for (attempt in 0 until maxAttempts) {
53
+ try {
54
+ when (val result = fetcher.fetch()) {
55
+ is ConversionResult.Resolved -> return result.link
56
+ ConversionResult.Pending -> Unit
57
+ }
58
+ } catch (error: Throwable) {
59
+ val ingest = error as? IngestError
60
+ if (ingest != null && !ingest.isRetryable) return null
61
+ // Retryable, or a transport failure: fall through to the backoff.
62
+ }
63
+
64
+ if (attempt < maxAttempts - 1) {
65
+ sleep(backoffMs.getOrElse(attempt) { backoffMs.lastOrNull() ?: 0L })
66
+ }
67
+ }
68
+ return null
69
+ }
@@ -0,0 +1,436 @@
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
+ /** B1: track properties PII scrub'u. Varsayılan-açık; yalnızca açık bayrakla
43
+ * kapatılır (opt-out) ve bu dokümante risklidir. Swift EngineConfig ile aynı. */
44
+ val piiScrubEnabled: Boolean = true,
45
+ )
46
+
47
+ /**
48
+ * Owns every piece of mutable SDK state.
49
+ *
50
+ * **Confinement, not locking.** Commands arrive through [CommandPipe] and are
51
+ * handled one at a time on a single thread, which reproduces sdk-core's
52
+ * single-threaded semantics — the property that makes the conformance vectors
53
+ * meaningful in the first place. This class takes no locks deliberately: a lock
54
+ * here would hide a confinement violation rather than prevent one.
55
+ */
56
+ internal class AdvenueEngine(
57
+ private val config: EngineConfig,
58
+ private val store: KeyValueStore,
59
+ private val clock: Clock,
60
+ scheduler: Scheduler,
61
+ private val uuid: UuidSource,
62
+ private val transport: EventTransport? = null,
63
+ private val onDrop: (List<ClientEvent>) -> Unit = {},
64
+ private val random: () -> Double = { Math.random() },
65
+ private val onError: (String, Throwable) -> Unit = { _, _ -> },
66
+ ) {
67
+ private val queue = EventQueue(store, config.maxQueueSize, scheduler)
68
+ private val sessions =
69
+ SessionTracker(store, clock, config.sessionWindowMs, uuid)
70
+
71
+ private var consent: Boolean = store.getString(CONSENT_KEY) == "granted"
72
+ private var forgotten = false
73
+ private var customerUserId: String? = store.getString(USER_ID_KEY)
74
+ private var idfa: String? = null
75
+ private var gaid: String? = null
76
+ private var androidId: String? = null
77
+ private var appInstanceId: String? = null
78
+ /** B2: rıza bayrağı — her olaya işlenir, persist edilmez (kimlik gibi). */
79
+ private var limitAdTracking: Boolean? = null
80
+ private var consentData: Consent? = readConsentData()
81
+ private var pushToken: String? = null
82
+ private var pushProvider: String? = null
83
+ private var flushing = false
84
+ private var consecutiveFailures = 0
85
+ private var backoffUntilMs = 0L
86
+
87
+ /** Enqueues an event, or refuses it and says why. Mirrors sdk-core's `track()`. */
88
+ public fun track(
89
+ name: String,
90
+ properties: Map<String, Any?>? = null,
91
+ type: String = "custom",
92
+ ): Boolean {
93
+ if (forgotten || (config.requireConsent && !consent)) return false
94
+ checkTrackInput(name, properties)?.let { rejection ->
95
+ onError("track.rejected:$rejection", IngestError(400))
96
+ return false
97
+ }
98
+ // B1: PII scrub (Swift PIIScrub ile aynı sözleşme).
99
+ val scrubbed = if (config.piiScrubEnabled) PiiScrub.scrub(properties) else properties
100
+ queue.enqueue(newEvent(type = type, name = name, properties = scrubbed))
101
+ return true
102
+ }
103
+
104
+ public fun setConsent(granted: Boolean) {
105
+ consent = granted
106
+ store.setString(CONSENT_KEY, if (granted) "granted" else "denied")
107
+ }
108
+
109
+ /**
110
+ * Records granular ad-platform consent. Last write wins, and it is persisted,
111
+ * so it survives restarts — a stated preference silently reverting on
112
+ * relaunch is the failure this guards.
113
+ */
114
+ internal fun setConsentData(consent: Consent?) {
115
+ if (forgotten) return
116
+ consentData = consent
117
+ if (consent == null) store.remove(CONSENT_DATA_KEY)
118
+ else store.setString(CONSENT_DATA_KEY, writeCanonicalJson(consent.toMap()))
119
+ }
120
+
121
+ internal fun getConsentData(): Consent? = consentData
122
+
123
+ internal fun getConsent(): Boolean = consent
124
+
125
+ /**
126
+ * Registers the device's push token for uninstall measurement (#26).
127
+ *
128
+ * The host app owns push registration: the SDK never asks for the
129
+ * notification permission and never displays anything. Pass the token your
130
+ * push library already gives you, on every launch — the OS can rotate it at
131
+ * any time, and a stale token is the one thing that makes uninstall
132
+ * measurement report churn that did not happen.
133
+ */
134
+ internal fun setPushToken(token: String?, provider: String? = null) {
135
+ if (forgotten) return
136
+ if (token == null) {
137
+ pushToken = null
138
+ pushProvider = null
139
+ return
140
+ }
141
+ val trimmed = token.trim()
142
+ if (!isValidPushToken(trimmed)) {
143
+ pushToken = null
144
+ pushProvider = null
145
+ onError("push.setPushToken", IngestError(400))
146
+ return
147
+ }
148
+ pushToken = trimmed
149
+ pushProvider = provider ?: if (config.platform == "android") "fcm" else "apns"
150
+ }
151
+
152
+ private fun readConsentData(): Consent? =
153
+ (parseJson(store.getString(CONSENT_DATA_KEY) ?: "") as? Map<*, *>)?.let(Consent::fromMap)
154
+
155
+ public fun setUserId(id: String?) {
156
+ // B6: 128 üstü id tüm batch'i 400'e düşürür (ingest bütün-batch ayrıştırır).
157
+ // Reddet, persist etme, onError ile bildir (Limits.kt deseni).
158
+ if (id != null && id.length > MAX_USER_ID_LENGTH) {
159
+ onError("account.setUserId:id_too_long", IngestError(400))
160
+ return
161
+ }
162
+ customerUserId = id
163
+ if (id != null) store.setString(USER_ID_KEY, id) else store.remove(USER_ID_KEY)
164
+ }
165
+
166
+ /**
167
+ * Identity attached to every subsequent event. Not persisted: it is
168
+ * re-resolved each launch, because the advertising id and its limit flag can
169
+ * both change between them.
170
+ */
171
+ public fun setIdentity(
172
+ gaid: String?,
173
+ androidId: String?,
174
+ appInstanceId: String?,
175
+ idfa: String? = null,
176
+ limitAdTracking: Boolean? = null,
177
+ ) {
178
+ this.gaid = gaid
179
+ this.androidId = androidId
180
+ this.appInstanceId = appInstanceId
181
+ this.idfa = idfa
182
+ this.limitAdTracking = limitAdTracking
183
+ }
184
+
185
+ /**
186
+ * The Firebase App Instance ID alone. Separate from [setIdentity] because it
187
+ * arrives on its own schedule — an app can set it at any point, and folding
188
+ * it into the multi-field setter would clear the advertising identity the
189
+ * install enrichment resolved.
190
+ */
191
+ public fun setAppInstanceId(id: String?) {
192
+ this.appInstanceId = id
193
+ }
194
+
195
+ /**
196
+ * The first-open event, at most once per installation.
197
+ *
198
+ * Ordering is deliberate. The consent gate comes first, so a refused install
199
+ * leaves no flag and can still fire once consent arrives. The flag is written
200
+ * last, so a crash between the event and the flag costs a duplicate the
201
+ * backend dedup window absorbs — a permanently missing install is the worse
202
+ * failure.
203
+ */
204
+ public fun trackInstall(properties: Map<String, Any?>? = null): Boolean {
205
+ if (forgotten || (config.requireConsent && !consent)) return false
206
+ if (store.getString(INSTALL_SENT_KEY) == "1") return false
207
+ // B1: install properties de scrub kapsamındadır.
208
+ val scrubbed = if (config.piiScrubEnabled) PiiScrub.scrub(properties) else properties
209
+ queue.enqueue(newEvent(type = "install", name = "install", properties = scrubbed))
210
+ store.setString(INSTALL_SENT_KEY, "1")
211
+ return true
212
+ }
213
+
214
+ /**
215
+ * Erasure, including the install-scoped identifiers.
216
+ *
217
+ * They are wiped HERE rather than by the facade, and the ordering is the
218
+ * reason. The facade's caller runs on its own thread while the pipe may still
219
+ * be holding a queued `trackInstall`; a wipe performed outside the pipe races
220
+ * that command and the flag is written back after the erasure. Routing it
221
+ * through the same ordered ingress puts the wipe behind everything already
222
+ * submitted.
223
+ *
224
+ * The store routes these three keys to `noBackupFilesDir` — see
225
+ * `CompositeStore`. Clearing only the preferences half is the layering trap
226
+ * the spec recorded: an erasure that leaves the identifiers behind and looks
227
+ * finished.
228
+ */
229
+ public fun forgetMe() {
230
+ forgotten = true
231
+ queue.clear()
232
+ sessions.reset()
233
+ consent = false
234
+ consentData = null
235
+ pushToken = null
236
+ pushProvider = null
237
+ customerUserId = null
238
+ idfa = null
239
+ gaid = null
240
+ androidId = null
241
+ appInstanceId = null
242
+ for (key in
243
+ listOf(
244
+ CONSENT_KEY,
245
+ CONSENT_DATA_KEY,
246
+ SESSION_STATE_KEY,
247
+ USER_ID_KEY,
248
+ DEVICE_ID_KEY,
249
+ INSTALLATION_ID_KEY,
250
+ INSTALL_SENT_KEY,
251
+ // queue.clear() above persists an empty blob synchronously; remove it
252
+ // so erasure leaves no queue file behind either. Safe to do last:
253
+ // `forgotten` stops every later enqueue, so nothing rewrites it.
254
+ QUEUE_KEY,
255
+ )) {
256
+ store.remove(key)
257
+ }
258
+ }
259
+
260
+ public fun notifyForeground() {
261
+ if (forgotten || (config.requireConsent && !consent)) return
262
+ sessions.handleForeground().forEach { track(it.name, it.properties, type = "session") }
263
+ }
264
+
265
+ public fun notifyBackground() {
266
+ if (forgotten || (config.requireConsent && !consent)) return
267
+ sessions.handleBackground()?.let { track(it.name, it.properties, type = "session") }
268
+ }
269
+
270
+ public fun pendingEventIds(): List<String> = queue.peek(Int.MAX_VALUE).map { it.id }
271
+
272
+ /** Test surface: the ids alone cannot say what a field carries. */
273
+ public fun pendingEvents(): List<ClientEvent> = queue.peek(Int.MAX_VALUE)
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Flush.
277
+ //
278
+ // The DECISION logic below runs on the engine's thread and nowhere else; the
279
+ // network call is the only part that may run elsewhere. That split is why the
280
+ // three steps are separate methods rather than one loop: `CommandPipe` drives
281
+ // them across two threads so a stalled request cannot block the commands
282
+ // queued behind it, while `flush()` drives the same three methods inline for
283
+ // callers and for the vectors. One implementation of the decisions, two
284
+ // drivers — a second copy is exactly the drift the vectors exist to prevent.
285
+ // ---------------------------------------------------------------------------
286
+
287
+ private var inFlight: List<ClientEvent>? = null
288
+ private var isolationCursor: MutableList<ClientEvent>? = null
289
+
290
+ /** Whether a transport is wired at all. */
291
+ internal fun canFlush(): Boolean = transport != null
292
+
293
+ /** Decides what to send. Null means there is nothing to do. */
294
+ internal fun beginFlush(): List<ClientEvent>? {
295
+ if (transport == null || flushing || queue.size == 0 || clock.nowMs() < backoffUntilMs) {
296
+ return null
297
+ }
298
+ // Clamped to the wire's limit, not trusted. The server answers 400 for a
299
+ // larger batch and a 400 is not retryable, so an app that set 200 would
300
+ // have every batch rejected and then re-sent one event at a time by the
301
+ // poison-isolation path: nothing lost, and every flush costing 1 + N
302
+ // requests forever. The constant said 100 and enforced nothing until now.
303
+ val events = queue.peek(minOf(config.batchSize, MAX_BATCH_SIZE))
304
+ if (events.isEmpty()) return null
305
+ flushing = true
306
+ inFlight = events
307
+ return events
308
+ }
309
+
310
+ /**
311
+ * Consumes the outcome of the batch send. Returns the first event to isolate,
312
+ * or null when the flush is finished.
313
+ */
314
+ internal fun onBatchOutcome(error: Throwable?): ClientEvent? {
315
+ val events = inFlight ?: return null
316
+ if (error == null) {
317
+ queue.ack(events)
318
+ consecutiveFailures = 0
319
+ backoffUntilMs = 0L
320
+ endFlush()
321
+ return null
322
+ }
323
+
324
+ val ingest = error as? IngestError
325
+ if (ingest != null && !ingest.isRetryable) {
326
+ // Poison payload. Ingest parses a batch as a whole and answers one 400 for
327
+ // all of it, so the offender has to be found rather than the batch
328
+ // discarded. A 4xx is not an outage, so it does not arm the backoff.
329
+ onError("flush.poison", error)
330
+ val cursor = events.toMutableList()
331
+ isolationCursor = cursor
332
+ return cursor.removeAt(0)
333
+ }
334
+
335
+ // Transient (network, 5xx, 429): keep the batch and back off, so a fleet
336
+ // recovering from an outage does not retry in lockstep.
337
+ onError("flush.transport", error)
338
+ armBackoff()
339
+ endFlush()
340
+ return null
341
+ }
342
+
343
+ /**
344
+ * Consumes the outcome of one isolated event and returns the next, or null
345
+ * when isolation is over. Stops at the first transient error, so a network
346
+ * drop mid-isolation cannot turn deliverable events into dropped ones.
347
+ */
348
+ internal fun onSingleOutcome(event: ClientEvent, error: Throwable?): ClientEvent? {
349
+ if (error == null) {
350
+ queue.ack(listOf(event))
351
+ } else {
352
+ val ingest = error as? IngestError
353
+ if (ingest == null || ingest.isRetryable) {
354
+ endFlush()
355
+ return null
356
+ }
357
+ queue.ack(listOf(event))
358
+ onDrop(listOf(event))
359
+ }
360
+
361
+ val cursor = isolationCursor
362
+ if (cursor == null || cursor.isEmpty()) {
363
+ endFlush()
364
+ return null
365
+ }
366
+ return cursor.removeAt(0)
367
+ }
368
+
369
+ private fun endFlush() {
370
+ flushing = false
371
+ inFlight = null
372
+ isolationCursor = null
373
+ }
374
+
375
+ /**
376
+ * Inline driver: sends on the calling thread. Used by callers without a pipe
377
+ * and by the conformance vectors. Never throws — a timer-driven call is not
378
+ * awaited, so a transient failure simply leaves the batch buffered.
379
+ */
380
+ public fun flush() {
381
+ val batch = beginFlush() ?: return
382
+ var next = onBatchOutcome(send(batch))
383
+ while (next != null) {
384
+ next = onSingleOutcome(next, send(listOf(next)))
385
+ }
386
+ }
387
+
388
+ /** The single network call, returning its failure rather than throwing. */
389
+ internal fun send(events: List<ClientEvent>): Throwable? =
390
+ try {
391
+ transport?.send(events)
392
+ null
393
+ } catch (error: Throwable) {
394
+ error
395
+ }
396
+
397
+ private fun armBackoff() {
398
+ consecutiveFailures += 1
399
+ val delay =
400
+ backoffDelayMs(consecutiveFailures, config.retryBaseMs, config.retryCapMs, random())
401
+ backoffUntilMs = clock.nowMs() + delay.toLong()
402
+ }
403
+
404
+ /** Test surface: the `flush/` vectors assert that a 4xx leaves this at zero. */
405
+ public fun consecutiveFailureCount(): Int = consecutiveFailures
406
+
407
+ private fun newEvent(
408
+ type: String,
409
+ name: String,
410
+ properties: Map<String, Any?>?,
411
+ ): ClientEvent =
412
+ ClientEvent(
413
+ id = uuid.next(),
414
+ deviceId = config.deviceId,
415
+ type = type,
416
+ name = name,
417
+ timestamp = iso8601(clock.nowMs()),
418
+ platform = config.platform,
419
+ installationId = config.installationId,
420
+ appVersion = config.appVersion,
421
+ osVersion = config.osVersion,
422
+ sdkVersion = config.sdkVersion,
423
+ customerUserId = customerUserId,
424
+ idfa = idfa,
425
+ gaid = gaid,
426
+ limitAdTracking = limitAdTracking,
427
+ androidId = androidId,
428
+ appInstanceId = appInstanceId,
429
+ consent = consentData?.toMap(),
430
+ // Lifecycle events only. A push token is ~180 bytes and the registry
431
+ // needs it periodically, not on every custom event in a 100-event batch.
432
+ pushToken = if (type == "install" || type == "session") pushToken else null,
433
+ pushProvider = if (type == "install" || type == "session") pushProvider else null,
434
+ properties = properties,
435
+ )
436
+ }
@@ -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
+ }