@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,607 @@
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
+ // B2: rıza bayrağı olaya işlenir.
151
+ event.limitAdTracking = limitAdTracking
152
+ event.vendorId = vendorId
153
+ event.appInstanceId = appInstanceId
154
+ event.consent = consentData
155
+ // Lifecycle events only — see trackInstall.
156
+ if type == "session" {
157
+ event.pushToken = pushToken
158
+ event.pushProvider = pushProvider
159
+ }
160
+ event.properties = config.piiScrubEnabled ? PIIScrub.scrub(properties) : properties
161
+ queue.enqueue(event)
162
+ return true
163
+ }
164
+
165
+ /// Identity attached to every subsequent event. Not persisted: it is
166
+ /// re-resolved each launch, because ATT status and the vendor id can both
167
+ /// change between them.
168
+ /// B2: rıza bayrağı — her olaya işlenir, persist edilmez (kimlik gibi).
169
+ private var limitAdTracking: Bool?
170
+
171
+ public func setIdentity(
172
+ idfa: String?, vendorId: String?, appInstanceId: String?,
173
+ limitAdTracking: Bool? = nil
174
+ ) {
175
+ self.idfa = idfa
176
+ self.vendorId = vendorId
177
+ self.appInstanceId = appInstanceId
178
+ self.limitAdTracking = limitAdTracking
179
+ }
180
+
181
+ /// The Firebase App Instance ID alone. Separate from `setIdentity` because it
182
+ /// arrives on its own schedule — an app can set it at any point, and folding
183
+ /// it into the three-field setter would clear the advertising identity the
184
+ /// install enrichment resolved.
185
+ public func setAppInstanceId(_ id: String?) {
186
+ appInstanceId = id
187
+ }
188
+
189
+ /// The first-open event, at most once per installation. Returns whether it
190
+ /// was recorded.
191
+ ///
192
+ /// Ordering is deliberate. The consent gate comes first, so a refused install
193
+ /// leaves no flag and can still fire once consent arrives. The flag is
194
+ /// written last, so a crash between the event and the flag costs a duplicate
195
+ /// the backend dedup window absorbs — a permanently missing install is the
196
+ /// worse failure.
197
+ @discardableResult
198
+ public func trackInstall(
199
+ adservicesToken: String?, properties: [String: AdvenueValue]? = nil,
200
+ attestation: AttestationResult? = nil, attestationChallenge: String? = nil
201
+ ) -> Bool {
202
+ if forgotten { return false }
203
+ if config.requireConsent && !consent {
204
+ installDeferred = (adservicesToken, properties, attestation, attestationChallenge)
205
+ return false
206
+ }
207
+ if store.string(forKey: INSTALL_SENT_KEY) == "1" { return false }
208
+
209
+ var event = ClientEvent(
210
+ id: uuid.next(), deviceId: config.deviceId, type: "install", name: "install",
211
+ timestamp: EventEncoding.iso8601(ms: clock.nowMs()), platform: config.platform)
212
+ event.installationId = config.installationId
213
+ event.appVersion = config.appVersion
214
+ event.osVersion = config.osVersion
215
+ event.sdkVersion = config.sdkVersion
216
+ event.customerUserId = customerUserId
217
+ event.idfa = idfa
218
+ // B2: rıza bayrağı install olayına da işlenir.
219
+ event.limitAdTracking = limitAdTracking
220
+ event.vendorId = vendorId
221
+ event.appInstanceId = appInstanceId
222
+ event.consent = consentData
223
+ // Lifecycle events only. A push token is ~180 bytes and the registry needs
224
+ // it periodically, not on every custom event in a 100-event batch.
225
+ event.pushToken = pushToken
226
+ event.pushProvider = pushProvider
227
+ // Install-only: an attribution input, not a per-event property.
228
+ event.adservicesToken = adservicesToken
229
+ if let attestation {
230
+ event.attestationToken = attestation.attestationObject
231
+ event.attestationType = "app-attest"
232
+ event.attestationKeyId = attestation.keyId
233
+ event.attestationChallenge = attestationChallenge
234
+ }
235
+ // Merged rather than replacing: a caller-supplied property of the same name
236
+ // is the app's own and wins.
237
+ let merged: [String: AdvenueValue]?
238
+ if let deviceInfo {
239
+ merged = deviceInfo.merging(properties ?? [:]) { _, caller in caller }
240
+ } else {
241
+ merged = properties
242
+ }
243
+ event.properties = config.piiScrubEnabled ? PIIScrub.scrub(merged) : merged
244
+ queue.enqueue(event)
245
+
246
+ store.set("1", forKey: INSTALL_SENT_KEY)
247
+ return true
248
+ }
249
+
250
+ public func setConsent(_ granted: Bool) {
251
+ consent = granted
252
+ store.set(granted ? "granted" : "denied", forKey: CONSENT_KEY)
253
+
254
+ // Granting consent releases an install that was refused for the lack of it.
255
+ guard granted, let deferred = installDeferred else { return }
256
+ installDeferred = nil
257
+ trackInstall(
258
+ adservicesToken: deferred.token, properties: deferred.properties,
259
+ attestation: deferred.attestation, attestationChallenge: deferred.challenge)
260
+ }
261
+
262
+ /// Granular ad-platform consent. Last write wins and it is persisted, so it
263
+ /// survives restarts — a stated preference silently reverting on relaunch is
264
+ /// the failure this guards.
265
+ public func setConsentData(_ consent: Consent?) {
266
+ guard !forgotten else { return }
267
+ consentData = consent
268
+ guard let consent else {
269
+ store.removeObject(forKey: CONSENT_DATA_KEY)
270
+ return
271
+ }
272
+ if let data = try? EventEncoding.canonicalEncoder().encode(consent) {
273
+ store.set(String(decoding: data, as: UTF8.self), forKey: CONSENT_DATA_KEY)
274
+ }
275
+ }
276
+
277
+ public func getConsentData() -> Consent? { consentData }
278
+
279
+ /// Device metadata attached to the install event, where Meta CAPI reads it.
280
+ public func setDeviceInfo(_ info: [String: AdvenueValue]) { deviceInfo = info }
281
+
282
+ /// Arms SKAN. Without a conversion-value config there is nothing to report,
283
+ /// so the machine is not built at all rather than built and idle.
284
+ ///
285
+ /// The machine is constructed **here**, on the actor, from Sendable
286
+ /// ingredients. Building it outside and passing it in is what Swift 6 refuses
287
+ /// — it holds the store, which belongs to this actor — and the refusal is
288
+ /// correct rather than something to silence with @unchecked.
289
+ public func enableSkan(
290
+ mapper: ConversionValueMapper,
291
+ currency: String?,
292
+ installationId: String,
293
+ reporter: any SkanReporter,
294
+ configVersion: Int = 0
295
+ ) {
296
+ skan = SkanStateMachine(
297
+ store: store, clock: clock, installationId: installationId,
298
+ mapper: mapper, currency: currency, configVersion: configVersion)
299
+ skanReporter = reporter
300
+ reporter.register()
301
+ }
302
+
303
+ /// Feeds SKAN and reports if the value moved.
304
+ ///
305
+ /// `confirm` runs only after the reporter returns without throwing. A device
306
+ /// that recorded a value it never sent would refuse to send it again, and
307
+ /// that window would report nothing for the rest of its life.
308
+ public func recordSkan(
309
+ event: String?, revenueMicros: String? = nil, revenueCurrency: String? = nil
310
+ ) async {
311
+ // Apple's conversion value is a measurement like any other, so it is gated
312
+ // like any other. Reporting revenue for a user who has not consented is a
313
+ // compliance failure, not a parity detail.
314
+ if forgotten || (config.requireConsent && !consent) { return }
315
+ guard let skan, let reporter = skanReporter else { return }
316
+ guard let update = skan.record(
317
+ event: event, revenueMicros: revenueMicros, revenueCurrency: revenueCurrency)
318
+ else { return }
319
+
320
+ do {
321
+ try await reporter.update(
322
+ fine: update.fineValue, coarse: update.coarseValue, lockWindow: update.lockWindow)
323
+ skan.confirm(update)
324
+ } catch {
325
+ skan.abandon(update)
326
+ onError("skan.update", error)
327
+ }
328
+ }
329
+
330
+ /// Registers the device's push token for uninstall measurement (#26).
331
+ ///
332
+ /// The host app owns push registration: the SDK never asks for the
333
+ /// notification permission and never displays anything. Pass the token your
334
+ /// push library already gives you, on every launch — the OS can rotate it at
335
+ /// any time, and a stale token is the one thing that makes uninstall
336
+ /// measurement report churn that did not happen.
337
+ ///
338
+ /// `provider` should be passed explicitly by an iOS app using Firebase
339
+ /// Messaging: that app holds an FCM token, and probing it against APNs would
340
+ /// look like an uninstall on every device.
341
+ public func setPushToken(_ token: String?, provider: String? = nil) {
342
+ guard !forgotten else { return }
343
+ guard let token else {
344
+ pushToken = nil
345
+ pushProvider = nil
346
+ return
347
+ }
348
+ let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
349
+ guard isValidPushToken(trimmed) else {
350
+ pushToken = nil
351
+ pushProvider = nil
352
+ onError("push.setPushToken", IngestError(status: 400))
353
+ return
354
+ }
355
+ pushToken = trimmed
356
+ pushProvider = provider ?? (config.platform == "android" ? "fcm" : "apns")
357
+ }
358
+
359
+
360
+ public func setUserId(_ id: String?) {
361
+ // B6: 128 üstü id tüm batch'i 400'e düşürür. Reddet, persist etme.
362
+ if let id, id.count > 128 {
363
+ onError("account.setUserId:id_too_long", IngestError(status: 400))
364
+ return
365
+ }
366
+ customerUserId = id
367
+ if let id {
368
+ store.set(id, forKey: USER_ID_KEY)
369
+ } else {
370
+ store.removeObject(forKey: USER_ID_KEY)
371
+ }
372
+ }
373
+
374
+ /// Erasure. NOTE: this clears only what lives in the `KeyValueStore`. The
375
+ /// durable device id lives in platform secure storage and is wiped by the
376
+ /// platform layer — a caller that invokes only this leaves the identifier
377
+ /// behind, which is an erasure that does not erase (spec §5).
378
+ public func forgetMe() {
379
+ forgotten = true
380
+ queue.clear()
381
+ sessions.reset()
382
+ consent = false
383
+ consentData = nil
384
+ pushToken = nil
385
+ pushProvider = nil
386
+ customerUserId = nil
387
+ // QUEUE_KEY last: queue.clear() above persists an empty blob synchronously,
388
+ // and `forgotten` stops every later enqueue, so nothing rewrites it.
389
+ for key in [CONSENT_KEY, CONSENT_DATA_KEY, SESSION_STATE_KEY, USER_ID_KEY, QUEUE_KEY] {
390
+ store.removeObject(forKey: key)
391
+ }
392
+ }
393
+
394
+ public func notifyForeground() {
395
+ let events = sessions.handleForeground()
396
+ for event in events {
397
+ track(event.name, properties: event.properties, type: "session")
398
+ }
399
+ // A conversion rule may name "session", and the RN SDK has always fed it.
400
+ // The generic name rather than session_start/session_end keeps one rule
401
+ // matching both a cold start and a return.
402
+ if !events.isEmpty {
403
+ Task { [weak self] in await self?.recordSkan(event: "session") }
404
+ }
405
+ }
406
+
407
+ public func notifyBackground() {
408
+ if let event = sessions.handleBackground() {
409
+ track(event.name, properties: event.properties, type: "session")
410
+ }
411
+ }
412
+
413
+ public func pendingEventIds() -> [String] {
414
+ queue.peek(Int.max).map(\.id)
415
+ }
416
+
417
+ /// Test surface: the ids alone cannot say what a field carries.
418
+ public func pendingEvents() -> [ClientEvent] { queue.peek(Int.max) }
419
+
420
+ /// Uploads buffered events. No-op when empty, re-entrancy guarded, and never
421
+ /// throws — a timer-driven call is unawaited, so a transient failure simply
422
+ /// leaves the batch buffered for the next attempt.
423
+ public func flush() async {
424
+ guard let transport else { return }
425
+ if flushing || queue.size == 0 || clock.nowMs() < backoffUntilMs { return }
426
+ flushing = true
427
+ defer { flushing = false }
428
+
429
+ // Clamped to the wire's limit, not trusted. The server answers 400 for a
430
+ // larger batch and a 400 is not retryable, so an app that set 200 would
431
+ // have every batch rejected and then re-sent one event at a time by the
432
+ // poison-isolation path: nothing lost, and every flush costing 1 + N
433
+ // requests forever. The constant said 100 and enforced nothing until now.
434
+ let events = queue.peek(min(config.batchSize, MAX_BATCH_SIZE))
435
+ if events.isEmpty { return }
436
+
437
+ do {
438
+ try await transport.send(events)
439
+ } catch {
440
+ if let ingest = error as? IngestError, !ingest.isRetryable {
441
+ // Poison payload. Ingest parses a batch as a whole and answers one 400
442
+ // for all of it, so the offender has to be found rather than the batch
443
+ // discarded. A 4xx is not an outage, so it does not arm the backoff.
444
+ onError("flush.poison", error)
445
+ await isolatePoison(events, transport)
446
+ } else {
447
+ // Transient (network, 5xx, 429): keep the batch and back off, so a
448
+ // fleet recovering from an outage does not retry in lockstep.
449
+ onError("flush.transport", error)
450
+ armBackoff()
451
+ }
452
+ return
453
+ }
454
+
455
+ queue.ack(events)
456
+ consecutiveFailures = 0
457
+ backoffUntilMs = 0
458
+ }
459
+
460
+ /// Re-sends a rejected batch one event at a time so a single poison event is
461
+ /// dropped while the rest are delivered or kept. Stops at the first transient
462
+ /// error, so a network drop mid-isolation cannot turn deliverable events into
463
+ /// dropped ones.
464
+ private func isolatePoison(_ events: [ClientEvent], _ transport: any EventTransport) async {
465
+ for event in events {
466
+ do {
467
+ try await transport.send([event])
468
+ queue.ack([event])
469
+ } catch {
470
+ guard let ingest = error as? IngestError, !ingest.isRetryable else { return }
471
+ queue.ack([event])
472
+ onDrop([event])
473
+ }
474
+ }
475
+ }
476
+
477
+ private func armBackoff() {
478
+ consecutiveFailures += 1
479
+ let delay = backoffDelayMs(
480
+ failures: consecutiveFailures, baseMs: config.retryBaseMs,
481
+ capMs: config.retryCapMs, random: random())
482
+ backoffUntilMs = clock.nowMs() + Int64(delay)
483
+ }
484
+
485
+ /// Test surface: the `flush/` vectors assert that a 4xx leaves this at zero.
486
+ public func consecutiveFailureCount() -> Int { consecutiveFailures }
487
+ }
488
+
489
+ /// A command submitted to the engine through the ordered ingress.
490
+ public enum Command: Sendable {
491
+ case track(name: String, properties: [String: AdvenueValue]?, type: String)
492
+ case setConsent(Bool)
493
+ case setUserId(String?)
494
+ case forgetMe
495
+ case foreground
496
+ case background
497
+ case flush
498
+ case setIdentity(idfa: String?, vendorId: String?, appInstanceId: String?, limitAdTracking: Bool? = nil)
499
+ case setAppInstanceId(String?)
500
+ case setConsentData(Consent?)
501
+ case setPushToken(token: String?, provider: String?)
502
+ case setDeviceInfo([String: AdvenueValue])
503
+ case recordSkan(event: String?, revenueMicros: String?, revenueCurrency: String?)
504
+ case enableSkan(
505
+ mapper: ConversionValueMapper, currency: String?, installationId: String,
506
+ reporter: any SkanReporter, configVersion: Int)
507
+ case trackInstall(
508
+ adservicesToken: String?, attestation: AttestationResult?, attestationChallenge: String?)
509
+ }
510
+
511
+ /// The ordered ingress: a synchronous, non-blocking `submit` feeding one
512
+ /// consumer task.
513
+ ///
514
+ /// Why a stream and not `Task { await engine.track(...) }` per call: an
515
+ /// unstructured `Task` does not preserve submission order, so an event could
516
+ /// overtake its own `session_start`. `continuation.yield` is synchronous,
517
+ /// non-blocking and ordered.
518
+ public final class CommandPipe: @unchecked Sendable {
519
+ private let continuation: AsyncStream<Command>.Continuation
520
+ private var consumer: Task<Void, Never>?
521
+
522
+ public init(
523
+ engine: AdvenueEngine,
524
+ onError: @escaping @Sendable (String, any Error) -> Void = { _, _ in }
525
+ ) {
526
+ // Unbounded on purpose: .bufferingNewest would silently DROP attribution
527
+ // events, and the real bound is applied downstream by the queue's cap.
528
+ let (stream, continuation) = AsyncStream<Command>.makeStream(
529
+ of: Command.self, bufferingPolicy: .unbounded)
530
+ self.continuation = continuation
531
+ self.consumer = Task {
532
+ for await command in stream {
533
+ // Each command is handled inside its own do/catch. An unhandled throw
534
+ // would END this task: the stream would stop draining, submit() would
535
+ // keep yielding into a growing buffer, nothing would send, nothing
536
+ // would error, and the app would not crash — attribution would simply
537
+ // stop. Silent total failure is the worst mode available, so this loop
538
+ // must be unkillable.
539
+ do {
540
+ try await Self.handle(command, engine)
541
+ } catch {
542
+ onError("engine.command", error)
543
+ }
544
+ }
545
+ }
546
+ }
547
+
548
+ private static func handle(_ command: Command, _ engine: AdvenueEngine) async throws {
549
+ switch command {
550
+ case .track(let name, let properties, let type):
551
+ await engine.track(name, properties: properties, type: type)
552
+ case .setConsent(let granted):
553
+ await engine.setConsent(granted)
554
+ case .setUserId(let id):
555
+ await engine.setUserId(id)
556
+ case .forgetMe:
557
+ await engine.forgetMe()
558
+ case .foreground:
559
+ await engine.notifyForeground()
560
+ case .background:
561
+ await engine.notifyBackground()
562
+ case .flush:
563
+ await engine.flush()
564
+ case .setIdentity(let idfa, let vendorId, let appInstanceId, let limitAdTracking):
565
+ await engine.setIdentity(
566
+ idfa: idfa, vendorId: vendorId, appInstanceId: appInstanceId,
567
+ limitAdTracking: limitAdTracking)
568
+ case .setAppInstanceId(let id):
569
+ await engine.setAppInstanceId(id)
570
+ case .trackInstall(let token, let attestation, let challenge):
571
+ await engine.trackInstall(
572
+ adservicesToken: token, attestation: attestation, attestationChallenge: challenge)
573
+ case .setConsentData(let consent):
574
+ await engine.setConsentData(consent)
575
+ case .setPushToken(let token, let provider):
576
+ await engine.setPushToken(token, provider: provider)
577
+ case .setDeviceInfo(let info):
578
+ await engine.setDeviceInfo(info)
579
+ case .recordSkan(let event, let micros, let currency):
580
+ await engine.recordSkan(event: event, revenueMicros: micros, revenueCurrency: currency)
581
+ case .enableSkan(let mapper, let currency, let installationId, let reporter, let version):
582
+ await engine.enableSkan(
583
+ mapper: mapper, currency: currency, installationId: installationId,
584
+ reporter: reporter, configVersion: version)
585
+ }
586
+ }
587
+
588
+ /// Synchronous, non-blocking and **ordered** — this is why the ingress is a
589
+ /// stream rather than a fresh Task per call.
590
+ public func submit(_ command: Command) {
591
+ continuation.yield(command)
592
+ }
593
+
594
+ public func shutdown() {
595
+ continuation.finish()
596
+ consumer = nil
597
+ }
598
+ }
599
+
600
+ /// Decodes the persisted DMA consent. Public because the platform facade
601
+ /// answers `consentData()` from the same bytes the engine loads — two decoders
602
+ /// would be two chances to disagree about what the device consented to.
603
+ public func readPersistedConsentData(_ store: KeyValueStore) -> Consent? {
604
+ guard let raw = store.string(forKey: CONSENT_DATA_KEY), let data = raw.data(using: .utf8)
605
+ else { return nil }
606
+ return try? JSONDecoder().decode(Consent.self, from: data)
607
+ }
@@ -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
+ }