@advenue/react-native 0.9.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 -368
  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 -675
  39. package/dist/index.d.cts +199 -336
  40. package/dist/index.d.ts +199 -336
  41. package/dist/index.js +182 -681
  42. package/ios/AdvenueIosModule.swift +144 -446
  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 +266 -830
  88. package/src/native-types.ts +74 -181
  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,587 @@
1
+ import Foundation
2
+
3
+ public struct EngineConfig: Sendable {
4
+ public var apiKey: String
5
+ public var platform: String
6
+ public var deviceId: String
7
+ public var installationId: String?
8
+ public var appVersion: String?
9
+ public var osVersion: String?
10
+ public var sdkVersion: String?
11
+ public var requireConsent: Bool
12
+ public var sessionWindowMs: Int64
13
+ public var maxQueueSize: Int
14
+ /// Events per request, and the threshold at which `track` flushes eagerly.
15
+ public var batchSize: Int
16
+ public var retryBaseMs: Double
17
+ public var retryCapMs: Double
18
+ /// M2: track properties PII scrub'u. Varsayılan-açık; yalnızca açık bayrakla
19
+ /// kapatılır (opt-out) ve bu dokümante risklidir.
20
+ public var piiScrubEnabled: Bool
21
+
22
+ public init(
23
+ apiKey: String, platform: String, deviceId: String, installationId: String? = nil,
24
+ appVersion: String? = nil, osVersion: String? = nil, sdkVersion: String? = nil,
25
+ requireConsent: Bool = false, sessionWindowMs: Int64 = DEFAULT_SESSION_WINDOW_MS,
26
+ maxQueueSize: Int = 10_000, batchSize: Int = 20,
27
+ retryBaseMs: Double = 1_000, retryCapMs: Double = 60_000,
28
+ piiScrubEnabled: Bool = true
29
+ ) {
30
+ self.batchSize = batchSize
31
+ self.retryBaseMs = retryBaseMs
32
+ self.retryCapMs = retryCapMs
33
+ self.piiScrubEnabled = piiScrubEnabled
34
+ self.apiKey = apiKey
35
+ self.platform = platform
36
+ self.deviceId = deviceId
37
+ self.installationId = installationId
38
+ self.appVersion = appVersion
39
+ self.osVersion = osVersion
40
+ self.sdkVersion = sdkVersion
41
+ self.requireConsent = requireConsent
42
+ self.sessionWindowMs = sessionWindowMs
43
+ self.maxQueueSize = maxQueueSize
44
+ }
45
+ }
46
+
47
+ /// The server rejects a batch of more than this: `eventBatchSchema` in
48
+ /// `packages/shared/src/events.ts` is `z.array(clientEventSchema).min(1).max(100)`.
49
+ ///
50
+ /// It lives in the core rather than beside the transport because it is a fact
51
+ /// about the wire, not about any one way of reaching it — and because the
52
+ /// engine has to clamp to it, which the transport cannot do from where it sits.
53
+ public let MAX_BATCH_SIZE = 100
54
+
55
+ /// Dies with the app; guards the one-per-install first-open event. Lives in the
56
+ /// core because the engine owns the guard — the Keychain is deliberately never
57
+ /// consulted for it, since a durable flag would suppress legitimate reinstalls.
58
+ public let INSTALL_SENT_KEY = "advenue.install_sent"
59
+ public let CONSENT_KEY = "advenue.consent"
60
+ public let CONSENT_DATA_KEY = "advenue.consent_data"
61
+ public let USER_ID_KEY = "advenue.user_id"
62
+
63
+ /// Owns every piece of mutable SDK state. Commands arrive through an ordered
64
+ /// `AsyncStream` and are handled one at a time, which reproduces sdk-core's
65
+ /// single-threaded semantics — the property that makes the conformance vectors
66
+ /// meaningful in the first place.
67
+ public actor AdvenueEngine {
68
+ private let config: EngineConfig
69
+ private let store: KeyValueStore
70
+ private let clock: Clock
71
+ private let uuid: UUIDSource
72
+ private let queue: EventQueue
73
+ private let sessions: SessionTracker
74
+ private let onError: @Sendable (String, any Error) -> Void
75
+ private let transport: (any EventTransport)?
76
+ private let onDrop: @Sendable ([ClientEvent]) -> Void
77
+ private let random: @Sendable () -> Double
78
+
79
+ private var consent: Bool
80
+ private var forgotten = false
81
+ private var customerUserId: String?
82
+ private var idfa: String?
83
+ private var vendorId: String?
84
+ private var appInstanceId: String?
85
+ private var consentData: Consent?
86
+ private var pushToken: String?
87
+ private var pushProvider: String?
88
+ private var deviceInfo: [String: AdvenueValue]?
89
+ /// An install refused because consent was closed, kept so granting consent
90
+ /// later still sends one. Without this an app using `requireConsent` that
91
+ /// gets consent after launch never sends an install at all — no install, no
92
+ /// attribution, for the life of that installation.
93
+ private var installDeferred: (token: String?, properties: [String: AdvenueValue]?,
94
+ attestation: AttestationResult?, challenge: String?)?
95
+ private var skan: SkanStateMachine?
96
+ private var skanReporter: (any SkanReporter)?
97
+ private var flushing = false
98
+ private var consecutiveFailures = 0
99
+ private var backoffUntilMs: Int64 = 0
100
+
101
+ public init(
102
+ config: EngineConfig,
103
+ store: KeyValueStore,
104
+ clock: Clock,
105
+ scheduler: Scheduler,
106
+ uuid: UUIDSource,
107
+ transport: (any EventTransport)? = nil,
108
+ onDrop: @escaping @Sendable ([ClientEvent]) -> Void = { _ in },
109
+ random: @escaping @Sendable () -> Double = { Double.random(in: 0..<1) },
110
+ onError: @escaping @Sendable (String, any Error) -> Void = { _, _ in }
111
+ ) {
112
+ self.config = config
113
+ self.store = store
114
+ self.clock = clock
115
+ self.uuid = uuid
116
+ self.transport = transport
117
+ self.onDrop = onDrop
118
+ self.random = random
119
+ self.onError = onError
120
+ self.queue = EventQueue(store: store, maxSize: config.maxQueueSize, scheduler: scheduler)
121
+ self.sessions = SessionTracker(
122
+ store: store, clock: clock, windowMs: config.sessionWindowMs, uuid: uuid)
123
+ self.consent = store.string(forKey: CONSENT_KEY) == "granted"
124
+ self.consentData = readPersistedConsentData(store)
125
+ }
126
+
127
+ /// Enqueues an event, or refuses it and says why. Returns whether it was
128
+ /// accepted, mirroring sdk-core's `track()`.
129
+ @discardableResult
130
+ public func track(
131
+ _ name: String,
132
+ properties: [String: AdvenueValue]? = nil,
133
+ type: String = "custom"
134
+ ) -> Bool {
135
+ if forgotten || (config.requireConsent && !consent) { return false }
136
+ if let rejection = checkTrackInput(name: name, properties: properties) {
137
+ onError("track.rejected:\(rejection.rawValue)", IngestError(status: 400))
138
+ return false
139
+ }
140
+
141
+ var event = ClientEvent(
142
+ id: uuid.next(), deviceId: config.deviceId, type: type, name: name,
143
+ timestamp: EventEncoding.iso8601(ms: clock.nowMs()), platform: config.platform)
144
+ event.installationId = config.installationId
145
+ event.appVersion = config.appVersion
146
+ event.osVersion = config.osVersion
147
+ event.sdkVersion = config.sdkVersion
148
+ event.customerUserId = customerUserId
149
+ event.idfa = idfa
150
+ event.vendorId = vendorId
151
+ event.appInstanceId = appInstanceId
152
+ event.consent = consentData
153
+ // Lifecycle events only — see trackInstall.
154
+ if type == "session" {
155
+ event.pushToken = pushToken
156
+ event.pushProvider = pushProvider
157
+ }
158
+ event.properties = config.piiScrubEnabled ? PIIScrub.scrub(properties) : properties
159
+ queue.enqueue(event)
160
+ return true
161
+ }
162
+
163
+ /// Identity attached to every subsequent event. Not persisted: it is
164
+ /// re-resolved each launch, because ATT status and the vendor id can both
165
+ /// change between them.
166
+ public func setIdentity(idfa: String?, vendorId: String?, appInstanceId: String?) {
167
+ self.idfa = idfa
168
+ self.vendorId = vendorId
169
+ self.appInstanceId = appInstanceId
170
+ }
171
+
172
+ /// The Firebase App Instance ID alone. Separate from `setIdentity` because it
173
+ /// arrives on its own schedule — an app can set it at any point, and folding
174
+ /// it into the three-field setter would clear the advertising identity the
175
+ /// install enrichment resolved.
176
+ public func setAppInstanceId(_ id: String?) {
177
+ appInstanceId = id
178
+ }
179
+
180
+ /// The first-open event, at most once per installation. Returns whether it
181
+ /// was recorded.
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
185
+ /// written last, so a crash between the event and the flag costs a duplicate
186
+ /// the backend dedup window absorbs — a permanently missing install is the
187
+ /// worse failure.
188
+ @discardableResult
189
+ public func trackInstall(
190
+ adservicesToken: String?, properties: [String: AdvenueValue]? = nil,
191
+ attestation: AttestationResult? = nil, attestationChallenge: String? = nil
192
+ ) -> Bool {
193
+ if forgotten { return false }
194
+ if config.requireConsent && !consent {
195
+ installDeferred = (adservicesToken, properties, attestation, attestationChallenge)
196
+ return false
197
+ }
198
+ if store.string(forKey: INSTALL_SENT_KEY) == "1" { return false }
199
+
200
+ var event = ClientEvent(
201
+ id: uuid.next(), deviceId: config.deviceId, type: "install", name: "install",
202
+ timestamp: EventEncoding.iso8601(ms: clock.nowMs()), platform: config.platform)
203
+ event.installationId = config.installationId
204
+ event.appVersion = config.appVersion
205
+ event.osVersion = config.osVersion
206
+ event.sdkVersion = config.sdkVersion
207
+ event.customerUserId = customerUserId
208
+ event.idfa = idfa
209
+ event.vendorId = vendorId
210
+ event.appInstanceId = appInstanceId
211
+ event.consent = consentData
212
+ // Lifecycle events only. A push token is ~180 bytes and the registry needs
213
+ // it periodically, not on every custom event in a 100-event batch.
214
+ event.pushToken = pushToken
215
+ event.pushProvider = pushProvider
216
+ // Install-only: an attribution input, not a per-event property.
217
+ event.adservicesToken = adservicesToken
218
+ if let attestation {
219
+ event.attestationToken = attestation.attestationObject
220
+ event.attestationType = "app-attest"
221
+ event.attestationKeyId = attestation.keyId
222
+ event.attestationChallenge = attestationChallenge
223
+ }
224
+ // Merged rather than replacing: a caller-supplied property of the same name
225
+ // is the app's own and wins.
226
+ let merged: [String: AdvenueValue]?
227
+ if let deviceInfo {
228
+ merged = deviceInfo.merging(properties ?? [:]) { _, caller in caller }
229
+ } else {
230
+ merged = properties
231
+ }
232
+ event.properties = config.piiScrubEnabled ? PIIScrub.scrub(merged) : merged
233
+ queue.enqueue(event)
234
+
235
+ store.set("1", forKey: INSTALL_SENT_KEY)
236
+ return true
237
+ }
238
+
239
+ public func setConsent(_ granted: Bool) {
240
+ consent = granted
241
+ store.set(granted ? "granted" : "denied", forKey: CONSENT_KEY)
242
+
243
+ // Granting consent releases an install that was refused for the lack of it.
244
+ guard granted, let deferred = installDeferred else { return }
245
+ installDeferred = nil
246
+ trackInstall(
247
+ adservicesToken: deferred.token, properties: deferred.properties,
248
+ attestation: deferred.attestation, attestationChallenge: deferred.challenge)
249
+ }
250
+
251
+ /// Granular ad-platform consent. Last write wins and it is persisted, so it
252
+ /// survives restarts — a stated preference silently reverting on relaunch is
253
+ /// the failure this guards.
254
+ public func setConsentData(_ consent: Consent?) {
255
+ guard !forgotten else { return }
256
+ consentData = consent
257
+ guard let consent else {
258
+ store.removeObject(forKey: CONSENT_DATA_KEY)
259
+ return
260
+ }
261
+ if let data = try? EventEncoding.canonicalEncoder().encode(consent) {
262
+ store.set(String(decoding: data, as: UTF8.self), forKey: CONSENT_DATA_KEY)
263
+ }
264
+ }
265
+
266
+ public func getConsentData() -> Consent? { consentData }
267
+
268
+ /// Device metadata attached to the install event, where Meta CAPI reads it.
269
+ public func setDeviceInfo(_ info: [String: AdvenueValue]) { deviceInfo = info }
270
+
271
+ /// Arms SKAN. Without a conversion-value config there is nothing to report,
272
+ /// so the machine is not built at all rather than built and idle.
273
+ ///
274
+ /// The machine is constructed **here**, on the actor, from Sendable
275
+ /// ingredients. Building it outside and passing it in is what Swift 6 refuses
276
+ /// — it holds the store, which belongs to this actor — and the refusal is
277
+ /// correct rather than something to silence with @unchecked.
278
+ public func enableSkan(
279
+ mapper: ConversionValueMapper,
280
+ currency: String?,
281
+ installationId: String,
282
+ reporter: any SkanReporter,
283
+ configVersion: Int = 0
284
+ ) {
285
+ skan = SkanStateMachine(
286
+ store: store, clock: clock, installationId: installationId,
287
+ mapper: mapper, currency: currency, configVersion: configVersion)
288
+ skanReporter = reporter
289
+ reporter.register()
290
+ }
291
+
292
+ /// Feeds SKAN and reports if the value moved.
293
+ ///
294
+ /// `confirm` runs only after the reporter returns without throwing. A device
295
+ /// that recorded a value it never sent would refuse to send it again, and
296
+ /// that window would report nothing for the rest of its life.
297
+ public func recordSkan(
298
+ event: String?, revenueMicros: String? = nil, revenueCurrency: String? = nil
299
+ ) async {
300
+ // Apple's conversion value is a measurement like any other, so it is gated
301
+ // like any other. Reporting revenue for a user who has not consented is a
302
+ // compliance failure, not a parity detail.
303
+ if forgotten || (config.requireConsent && !consent) { return }
304
+ guard let skan, let reporter = skanReporter else { return }
305
+ guard let update = skan.record(
306
+ event: event, revenueMicros: revenueMicros, revenueCurrency: revenueCurrency)
307
+ else { return }
308
+
309
+ do {
310
+ try await reporter.update(
311
+ fine: update.fineValue, coarse: update.coarseValue, lockWindow: update.lockWindow)
312
+ skan.confirm(update)
313
+ } catch {
314
+ skan.abandon(update)
315
+ onError("skan.update", error)
316
+ }
317
+ }
318
+
319
+ /// Registers the device's push token for uninstall measurement (#26).
320
+ ///
321
+ /// The host app owns push registration: the SDK never asks for the
322
+ /// notification permission and never displays anything. Pass the token your
323
+ /// push library already gives you, on every launch — the OS can rotate it at
324
+ /// any time, and a stale token is the one thing that makes uninstall
325
+ /// measurement report churn that did not happen.
326
+ ///
327
+ /// `provider` should be passed explicitly by an iOS app using Firebase
328
+ /// Messaging: that app holds an FCM token, and probing it against APNs would
329
+ /// look like an uninstall on every device.
330
+ public func setPushToken(_ token: String?, provider: String? = nil) {
331
+ guard !forgotten else { return }
332
+ guard let token else {
333
+ pushToken = nil
334
+ pushProvider = nil
335
+ return
336
+ }
337
+ let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
338
+ guard isValidPushToken(trimmed) else {
339
+ pushToken = nil
340
+ pushProvider = nil
341
+ onError("push.setPushToken", IngestError(status: 400))
342
+ return
343
+ }
344
+ pushToken = trimmed
345
+ pushProvider = provider ?? (config.platform == "android" ? "fcm" : "apns")
346
+ }
347
+
348
+
349
+ public func setUserId(_ id: String?) {
350
+ customerUserId = id
351
+ if let id {
352
+ store.set(id, forKey: USER_ID_KEY)
353
+ } else {
354
+ store.removeObject(forKey: USER_ID_KEY)
355
+ }
356
+ }
357
+
358
+ /// Erasure. NOTE: this clears only what lives in the `KeyValueStore`. The
359
+ /// durable device id lives in platform secure storage and is wiped by the
360
+ /// platform layer — a caller that invokes only this leaves the identifier
361
+ /// behind, which is an erasure that does not erase (spec §5).
362
+ public func forgetMe() {
363
+ forgotten = true
364
+ queue.clear()
365
+ sessions.reset()
366
+ consent = false
367
+ consentData = nil
368
+ pushToken = nil
369
+ pushProvider = nil
370
+ customerUserId = nil
371
+ for key in [CONSENT_KEY, CONSENT_DATA_KEY, SESSION_STATE_KEY, USER_ID_KEY] {
372
+ store.removeObject(forKey: key)
373
+ }
374
+ }
375
+
376
+ public func notifyForeground() {
377
+ let events = sessions.handleForeground()
378
+ for event in events {
379
+ track(event.name, properties: event.properties, type: "session")
380
+ }
381
+ // A conversion rule may name "session", and the RN SDK has always fed it.
382
+ // The generic name rather than session_start/session_end keeps one rule
383
+ // matching both a cold start and a return.
384
+ if !events.isEmpty {
385
+ Task { [weak self] in await self?.recordSkan(event: "session") }
386
+ }
387
+ }
388
+
389
+ public func notifyBackground() {
390
+ if let event = sessions.handleBackground() {
391
+ track(event.name, properties: event.properties, type: "session")
392
+ }
393
+ }
394
+
395
+ public func pendingEventIds() -> [String] {
396
+ queue.peek(Int.max).map(\.id)
397
+ }
398
+
399
+ /// Test surface: the ids alone cannot say what a field carries.
400
+ public func pendingEvents() -> [ClientEvent] { queue.peek(Int.max) }
401
+
402
+ /// Uploads buffered events. No-op when empty, re-entrancy guarded, and never
403
+ /// throws — a timer-driven call is unawaited, so a transient failure simply
404
+ /// leaves the batch buffered for the next attempt.
405
+ public func flush() async {
406
+ guard let transport else { return }
407
+ if flushing || queue.size == 0 || clock.nowMs() < backoffUntilMs { return }
408
+ flushing = true
409
+ defer { flushing = false }
410
+
411
+ // Clamped to the wire's limit, not trusted. The server answers 400 for a
412
+ // larger batch and a 400 is not retryable, so an app that set 200 would
413
+ // have every batch rejected and then re-sent one event at a time by the
414
+ // poison-isolation path: nothing lost, and every flush costing 1 + N
415
+ // requests forever. The constant said 100 and enforced nothing until now.
416
+ let events = queue.peek(min(config.batchSize, MAX_BATCH_SIZE))
417
+ if events.isEmpty { return }
418
+
419
+ do {
420
+ try await transport.send(events)
421
+ } catch {
422
+ if let ingest = error as? IngestError, !ingest.isRetryable {
423
+ // Poison payload. Ingest parses a batch as a whole and answers one 400
424
+ // for all of it, so the offender has to be found rather than the batch
425
+ // discarded. A 4xx is not an outage, so it does not arm the backoff.
426
+ onError("flush.poison", error)
427
+ await isolatePoison(events, transport)
428
+ } else {
429
+ // Transient (network, 5xx, 429): keep the batch and back off, so a
430
+ // fleet recovering from an outage does not retry in lockstep.
431
+ onError("flush.transport", error)
432
+ armBackoff()
433
+ }
434
+ return
435
+ }
436
+
437
+ queue.ack(events)
438
+ consecutiveFailures = 0
439
+ backoffUntilMs = 0
440
+ }
441
+
442
+ /// Re-sends a rejected batch one event at a time so a single poison event is
443
+ /// dropped while the rest are delivered or kept. Stops at the first transient
444
+ /// error, so a network drop mid-isolation cannot turn deliverable events into
445
+ /// dropped ones.
446
+ private func isolatePoison(_ events: [ClientEvent], _ transport: any EventTransport) async {
447
+ for event in events {
448
+ do {
449
+ try await transport.send([event])
450
+ queue.ack([event])
451
+ } catch {
452
+ guard let ingest = error as? IngestError, !ingest.isRetryable else { return }
453
+ queue.ack([event])
454
+ onDrop([event])
455
+ }
456
+ }
457
+ }
458
+
459
+ private func armBackoff() {
460
+ consecutiveFailures += 1
461
+ let delay = backoffDelayMs(
462
+ failures: consecutiveFailures, baseMs: config.retryBaseMs,
463
+ capMs: config.retryCapMs, random: random())
464
+ backoffUntilMs = clock.nowMs() + Int64(delay)
465
+ }
466
+
467
+ /// Test surface: the `flush/` vectors assert that a 4xx leaves this at zero.
468
+ public func consecutiveFailureCount() -> Int { consecutiveFailures }
469
+ }
470
+
471
+ /// A command submitted to the engine through the ordered ingress.
472
+ public enum Command: Sendable {
473
+ case track(name: String, properties: [String: AdvenueValue]?, type: String)
474
+ case setConsent(Bool)
475
+ case setUserId(String?)
476
+ case forgetMe
477
+ case foreground
478
+ case background
479
+ case flush
480
+ case setIdentity(idfa: String?, vendorId: String?, appInstanceId: String?)
481
+ case setAppInstanceId(String?)
482
+ case setConsentData(Consent?)
483
+ case setPushToken(token: String?, provider: String?)
484
+ case setDeviceInfo([String: AdvenueValue])
485
+ case recordSkan(event: String?, revenueMicros: String?, revenueCurrency: String?)
486
+ case enableSkan(
487
+ mapper: ConversionValueMapper, currency: String?, installationId: String,
488
+ reporter: any SkanReporter, configVersion: Int)
489
+ case trackInstall(
490
+ adservicesToken: String?, attestation: AttestationResult?, attestationChallenge: String?)
491
+ }
492
+
493
+ /// The ordered ingress: a synchronous, non-blocking `submit` feeding one
494
+ /// consumer task.
495
+ ///
496
+ /// Why a stream and not `Task { await engine.track(...) }` per call: an
497
+ /// unstructured `Task` does not preserve submission order, so an event could
498
+ /// overtake its own `session_start`. `continuation.yield` is synchronous,
499
+ /// non-blocking and ordered.
500
+ public final class CommandPipe: @unchecked Sendable {
501
+ private let continuation: AsyncStream<Command>.Continuation
502
+ private var consumer: Task<Void, Never>?
503
+
504
+ public init(
505
+ engine: AdvenueEngine,
506
+ onError: @escaping @Sendable (String, any Error) -> Void = { _, _ in }
507
+ ) {
508
+ // Unbounded on purpose: .bufferingNewest would silently DROP attribution
509
+ // events, and the real bound is applied downstream by the queue's cap.
510
+ let (stream, continuation) = AsyncStream<Command>.makeStream(
511
+ of: Command.self, bufferingPolicy: .unbounded)
512
+ self.continuation = continuation
513
+ self.consumer = Task {
514
+ for await command in stream {
515
+ // Each command is handled inside its own do/catch. An unhandled throw
516
+ // would END this task: the stream would stop draining, submit() would
517
+ // keep yielding into a growing buffer, nothing would send, nothing
518
+ // would error, and the app would not crash — attribution would simply
519
+ // stop. Silent total failure is the worst mode available, so this loop
520
+ // must be unkillable.
521
+ do {
522
+ try await Self.handle(command, engine)
523
+ } catch {
524
+ onError("engine.command", error)
525
+ }
526
+ }
527
+ }
528
+ }
529
+
530
+ private static func handle(_ command: Command, _ engine: AdvenueEngine) async throws {
531
+ switch command {
532
+ case .track(let name, let properties, let type):
533
+ await engine.track(name, properties: properties, type: type)
534
+ case .setConsent(let granted):
535
+ await engine.setConsent(granted)
536
+ case .setUserId(let id):
537
+ await engine.setUserId(id)
538
+ case .forgetMe:
539
+ await engine.forgetMe()
540
+ case .foreground:
541
+ await engine.notifyForeground()
542
+ case .background:
543
+ await engine.notifyBackground()
544
+ case .flush:
545
+ await engine.flush()
546
+ case .setIdentity(let idfa, let vendorId, let appInstanceId):
547
+ await engine.setIdentity(idfa: idfa, vendorId: vendorId, appInstanceId: appInstanceId)
548
+ case .setAppInstanceId(let id):
549
+ await engine.setAppInstanceId(id)
550
+ case .trackInstall(let token, let attestation, let challenge):
551
+ await engine.trackInstall(
552
+ adservicesToken: token, attestation: attestation, attestationChallenge: challenge)
553
+ case .setConsentData(let consent):
554
+ await engine.setConsentData(consent)
555
+ case .setPushToken(let token, let provider):
556
+ await engine.setPushToken(token, provider: provider)
557
+ case .setDeviceInfo(let info):
558
+ await engine.setDeviceInfo(info)
559
+ case .recordSkan(let event, let micros, let currency):
560
+ await engine.recordSkan(event: event, revenueMicros: micros, revenueCurrency: currency)
561
+ case .enableSkan(let mapper, let currency, let installationId, let reporter, let version):
562
+ await engine.enableSkan(
563
+ mapper: mapper, currency: currency, installationId: installationId,
564
+ reporter: reporter, configVersion: version)
565
+ }
566
+ }
567
+
568
+ /// Synchronous, non-blocking and **ordered** — this is why the ingress is a
569
+ /// stream rather than a fresh Task per call.
570
+ public func submit(_ command: Command) {
571
+ continuation.yield(command)
572
+ }
573
+
574
+ public func shutdown() {
575
+ continuation.finish()
576
+ consumer = nil
577
+ }
578
+ }
579
+
580
+ /// Decodes the persisted DMA consent. Public because the platform facade
581
+ /// answers `consentData()` from the same bytes the engine loads — two decoders
582
+ /// would be two chances to disagree about what the device consented to.
583
+ public func readPersistedConsentData(_ store: KeyValueStore) -> Consent? {
584
+ guard let raw = store.string(forKey: CONSENT_DATA_KEY), let data = raw.data(using: .utf8)
585
+ else { return nil }
586
+ return try? JSONDecoder().decode(Consent.self, from: data)
587
+ }
@@ -0,0 +1,89 @@
1
+ import Foundation
2
+
3
+ public let QUEUE_KEY = "advenue.queue"
4
+ /// Persist debounce window. Bursts inside it coalesce into one write.
5
+ public let PERSIST_DEBOUNCE_MS = 100
6
+
7
+ /// Durable FIFO event buffer, mirroring sdk-core's `EventQueue` including its
8
+ /// persisted blob shape — the RN inversion has to read what TypeScript wrote.
9
+ ///
10
+ /// Persist strategy: `enqueue` debounces so a burst costs one write; `ack` and
11
+ /// `clear` are flush boundaries and write synchronously, because stale storage
12
+ /// at those points means event loss on ack or double-send after a crash.
13
+ ///
14
+ /// Confined to the engine's single consumer task. Not thread-safe, and
15
+ /// deliberately so: the actor owns it, and a lock here would hide a mistake
16
+ /// rather than prevent one.
17
+ public final class EventQueue {
18
+ private var events: [ClientEvent]
19
+ private var persistToken: CancelToken?
20
+ private let store: KeyValueStore
21
+ private let maxSize: Int
22
+ private let scheduler: Scheduler
23
+
24
+ public init(store: KeyValueStore, maxSize: Int = 10_000, scheduler: Scheduler) {
25
+ self.store = store
26
+ self.maxSize = maxSize
27
+ self.scheduler = scheduler
28
+ self.events = EventQueue.load(store)
29
+ }
30
+
31
+ /// A corrupt blob loads as empty rather than throwing: bricking the SDK on
32
+ /// every launch is worse than losing an offline buffer.
33
+ private static func load(_ store: KeyValueStore) -> [ClientEvent] {
34
+ guard let raw = store.string(forKey: QUEUE_KEY), let data = raw.data(using: .utf8) else {
35
+ return []
36
+ }
37
+ return (try? JSONDecoder().decode([ClientEvent].self, from: data)) ?? []
38
+ }
39
+
40
+ public var size: Int { events.count }
41
+
42
+ public func enqueue(_ event: ClientEvent) {
43
+ events.append(event)
44
+ // Bound the buffer: a long offline period on a chatty app must not grow the
45
+ // persisted blob without limit. Recent events are the ones worth keeping.
46
+ if events.count > maxSize {
47
+ events.removeFirst(events.count - maxSize)
48
+ }
49
+ schedulePersist()
50
+ }
51
+
52
+ public func peek(_ max: Int) -> [ClientEvent] {
53
+ Array(events.prefix(max))
54
+ }
55
+
56
+ public func ack(_ sent: [ClientEvent]) {
57
+ guard !sent.isEmpty else { return }
58
+ let ids = Set(sent.map(\.id))
59
+ events.removeAll { ids.contains($0.id) }
60
+ forcePersist()
61
+ }
62
+
63
+ public func clear() {
64
+ events.removeAll()
65
+ forcePersist()
66
+ }
67
+
68
+ private func schedulePersist() {
69
+ guard persistToken == nil else { return }
70
+ persistToken = scheduler.schedule(afterMs: PERSIST_DEBOUNCE_MS) { [weak self] in
71
+ guard let self else { return }
72
+ self.persistToken = nil
73
+ self.write()
74
+ }
75
+ }
76
+
77
+ private func forcePersist() {
78
+ if let token = persistToken {
79
+ scheduler.cancel(token)
80
+ persistToken = nil
81
+ }
82
+ write()
83
+ }
84
+
85
+ private func write() {
86
+ guard let data = try? EventEncoding.canonicalEncoder().encode(events) else { return }
87
+ store.set(String(decoding: data, as: UTF8.self), forKey: QUEUE_KEY)
88
+ }
89
+ }