@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,596 @@
1
+ import Foundation
2
+
3
+ #if canImport(UIKit)
4
+ import UIKit
5
+ #endif
6
+
7
+ /// The public SDK. A static facade matching the React Native SDK's names, so a
8
+ /// developer moving to native reads the same API, delegating to state that is
9
+ /// replaceable and inspectable rather than to a hidden singleton.
10
+ public enum Advenue {
11
+ private static let state = FacadeState()
12
+
13
+ /// Starts the SDK. Safe to call from `didFinishLaunchingWithOptions`, and
14
+ /// safe to call twice: the previous instance is shut down first. Without
15
+ /// that, a second call leaves two consumer tasks draining one stream and
16
+ /// duplicate lifecycle observers, so events are processed twice or lost.
17
+ public static func initialize(_ config: AdvenueConfig) {
18
+ state.start(config)
19
+ }
20
+
21
+ /// Records an event. Synchronous, non-blocking and ordered.
22
+ public static func track(_ name: String, properties: [String: AdvenueValue]? = nil) {
23
+ state.submit(.track(name: name, properties: properties, type: "custom"))
24
+ // SKAN sees app events, never the SDK's own. An `adv_`-prefixed signal —
25
+ // adv_meta_aem, adv_skan_update — satisfying a conversion rule would move
26
+ // an advertiser's conversion value on the SDK's behalf.
27
+ if !name.hasPrefix("adv_") {
28
+ state.submit(.recordSkan(event: name, revenueMicros: nil, revenueCurrency: nil))
29
+ }
30
+ }
31
+
32
+ /// Reports revenue to SKAdNetwork, in canonical micros. A string rather than
33
+ /// a number because money must not go through a Double.
34
+ public static func recordSkanRevenue(micros: String, currency: String) {
35
+ state.submit(.recordSkan(event: nil, revenueMicros: micros, revenueCurrency: currency))
36
+ }
37
+
38
+ /// Reports revenue as the decimal amount a StoreKit price is quoted in
39
+ /// ("9.99"), converted to exact micros here. Returns false — recording
40
+ /// nothing — for an amount that is not a non-negative decimal with at most
41
+ /// six places; coercing a malformed one to zero would report a conversion
42
+ /// value the purchase did not earn.
43
+ @discardableResult
44
+ public static func recordSkanRevenue(amount: String, currency: String) -> Bool {
45
+ guard let micros = decimalToMicros(amount) else { return false }
46
+ recordSkanRevenue(micros: micros, currency: currency)
47
+ return true
48
+ }
49
+
50
+ public static func setUserId(_ id: String?) { state.submit(.setUserId(id)) }
51
+
52
+ public static func setConsent(_ granted: Bool) {
53
+ state.rememberConsent(granted)
54
+ state.submit(.setConsent(granted))
55
+ }
56
+
57
+ /// Whether tracking consent is currently granted. Persisted, so this is the
58
+ /// answer after a restart too — a caller that defaulted to false on every
59
+ /// cold start would silently discard a preference the user gave.
60
+ public static func trackingConsent() -> Bool { state.trackingConsent }
61
+
62
+ /// The granular DMA consent last set, or nil if none has been.
63
+ public static func consentData() -> Consent? { state.consentData }
64
+
65
+ /// The Firebase App Instance ID, when the app resolves it itself rather than
66
+ /// through `AdvenueFirebase`. Does not disturb the advertising identity.
67
+ public static func setAppInstanceId(_ id: String?) {
68
+ state.submit(.setAppInstanceId(id))
69
+ }
70
+
71
+ /// Granular ad-platform consent (Google DMA), forwarded by server-side
72
+ /// postbacks as gdpr_applies / ad_user_data / ad_personalization / ad_storage.
73
+ ///
74
+ /// Leave a field nil when the user has not been asked: "not stated" is not
75
+ /// "denied", and inventing false on their behalf records a refusal that never
76
+ /// happened.
77
+ public static func setConsentData(_ consent: Consent?) {
78
+ state.rememberConsentData(consent)
79
+ state.submit(.setConsentData(consent))
80
+ }
81
+
82
+ /// Registers the device's push token for uninstall measurement.
83
+ ///
84
+ /// Advenue never asks for the notification permission and never displays
85
+ /// anything. Pass the token your push library already gives you, on every
86
+ /// launch: the OS can rotate it, and a stale token is what makes uninstall
87
+ /// measurement report churn that did not happen.
88
+ ///
89
+ /// Pass `provider: "fcm"` if this app holds an FCM token rather than an APNs
90
+ /// one — probing an FCM token against APNs looks like an uninstall on every
91
+ /// device.
92
+ public static func setPushToken(_ token: String?, provider: String? = nil) {
93
+ state.submit(.setPushToken(token: token, provider: provider))
94
+ }
95
+
96
+ /// The app the ingestion service resolved this API key to, or nil until a
97
+ /// batch has been accepted.
98
+ ///
99
+ /// The API key is the SDK's entire app identity, so pasting the wrong one is
100
+ /// silent: events are still accepted, just recorded against another app, and
101
+ /// every screen the integrator checks is the one they believe they
102
+ /// configured. This is the answer to "which app am I actually writing to",
103
+ /// read from the device rather than inferred from the dashboard.
104
+ public static func resolvedAppId() -> String? { state.resolvedAppId }
105
+
106
+ /// Resolves the deferred deep link for this install, or nil for an organic
107
+ /// one. Safe to call once on first launch; the app routes on the result.
108
+ public static func resolveDeferredDeepLink() async -> DeepLink? {
109
+ await state.fetchDeferredDeepLink()
110
+ }
111
+
112
+ /// Erasure. Spans both stores — see `FacadeState.forgetMe`.
113
+ public static func forgetMe() { state.forgetMe() }
114
+
115
+ /// The device identifier, or nil while identity is deferred.
116
+ ///
117
+ /// Async because resolution can be deferred while the Keychain is locked. A
118
+ /// synchronous getter would have nothing to return there, and returning a
119
+ /// guess is exactly the bug this SDK is built to avoid.
120
+ public static func deviceId() async -> String? { state.currentDeviceId }
121
+
122
+ /// Forward from `application(_:open:options:)`. Callable **before**
123
+ /// `initialize`: a cold start from a link can run the app delegate first,
124
+ /// and the links that carry attribution are precisely the ones that would
125
+ /// be lost.
126
+ public static func processDeepLink(_ url: URL) { state.deepLink(url) }
127
+
128
+ /// Sends what is buffered. Safe to call at any time; a no-op when the queue
129
+ /// is empty or a backoff window is open.
130
+ public static func flush() { state.submit(.flush) }
131
+
132
+ public static func notifyForeground() { state.submit(.foreground) }
133
+
134
+ /// Backgrounding both closes the session and flushes: a batch stranded at the
135
+ /// moment the app leaves the foreground may not be sent for hours.
136
+ public static func notifyBackground() {
137
+ state.submit(.background)
138
+ state.submit(.flush)
139
+ }
140
+
141
+ /// Presents the ATT prompt. The app decides when; the SDK never prompts on
142
+ /// its own.
143
+ @discardableResult
144
+ public static func requestTrackingAuthorization() async -> TrackingAuthorization {
145
+ await AdvertisingIdentity().requestAuthorization()
146
+ }
147
+
148
+ /// Set by `AdvenueFirebase`; the base SDK knows only the shape, so
149
+ /// FirebaseAnalytics is never forced on a consumer who does not use it.
150
+ public static func setAppInstanceIdProvider(
151
+ _ provider: @escaping @Sendable () async -> String?
152
+ ) {
153
+ state.setAppInstanceIdProvider(provider)
154
+ }
155
+
156
+ public static func shutdown() { state.stop() }
157
+ }
158
+
159
+ /// Thread-safe holder for the app the server reported. Diagnostics only.
160
+ final class AcceptedAppId: @unchecked Sendable {
161
+ private let lock = NSLock()
162
+ private var value: String?
163
+
164
+ var current: String? {
165
+ lock.lock()
166
+ defer { lock.unlock() }
167
+ return value
168
+ }
169
+
170
+ func set(_ appId: String) {
171
+ lock.lock()
172
+ value = appId
173
+ lock.unlock()
174
+ }
175
+ }
176
+
177
+ /// Holds what a static facade cannot: the live engine, the command pipe, the
178
+ /// pre-init deep-link buffer and the resolved identity.
179
+ final class FacadeState: @unchecked Sendable {
180
+ private let lock = NSLock()
181
+ private var pipe: CommandPipe?
182
+ private var secure: (any SecureStore)?
183
+ private var store: (any KeyValueStore)?
184
+ private var resolvedDeviceId: String?
185
+ /// Set by the transport after a batch is accepted; diagnostics only.
186
+ private let acceptedAppId = AcceptedAppId()
187
+ private var startedConfig: AdvenueConfig?
188
+ private var pendingDeepLinks: [URL] = []
189
+ private var seenAemUrlHashes: Set<String> = []
190
+ private var appInstanceIdProvider: (@Sendable () async -> String?)?
191
+ private var flushTimer: DispatchSourceTimer?
192
+ /// Observers on the app's own lifecycle notifications, and the machine that
193
+ /// decides what they mean. Held so `stop()` can remove them: a stale observer
194
+ /// submitting into a finished pipe outlives the instance that made it.
195
+ private var lifecycleObservers: [NSObjectProtocol] = []
196
+ private var foreground = ForegroundTracker()
197
+ /// Read caches for the two consent values. The engine owns persistence; these
198
+ /// exist so a synchronous getter can answer without a round trip through the
199
+ /// pipe, and so a caller reading back its own `setConsent` never sees the
200
+ /// value it just replaced.
201
+ private var consentMirror = false
202
+ private var consentDataMirror: Consent?
203
+
204
+ /// `transport` is a parameter, not a hidden construction, because an unwired
205
+ /// transport is otherwise invisible: every component of the send path can be
206
+ /// green while nothing joins them. `SeamTests` injects a recorder here.
207
+ func start(
208
+ _ config: AdvenueConfig,
209
+ transport: (any EventTransport)? = nil,
210
+ sources: EnrichmentSources? = nil,
211
+ skan skanReporter: (any SkanReporter)? = nil
212
+ ) {
213
+ // Replace-and-shut-down, never add.
214
+ stop()
215
+
216
+ let store = UserDefaultsStore()
217
+ let secure = KeychainStore()
218
+ let uuid = SystemUUIDs()
219
+
220
+ let identity = resolveIdentity(secure: secure, store: store, uuid: uuid)
221
+ guard case .resolved(let deviceId, let installationId) = identity else {
222
+ // Deferred: the Keychain could not be read. Nothing is minted and
223
+ // nothing starts, so no event carries an invented identifier. The next
224
+ // launch after first unlock resolves it.
225
+ config.onError("identity.deferred", IngestError(status: 0))
226
+ return
227
+ }
228
+
229
+ let osVersion: String?
230
+ #if canImport(UIKit)
231
+ osVersion = UIDevice.current.systemVersion
232
+ #else
233
+ osVersion = nil
234
+ #endif
235
+
236
+ let eventTransport =
237
+ transport
238
+ ?? HttpTransport(
239
+ endpoint: config.endpoint, apiKey: config.apiKey,
240
+ signingSecret: config.signingSecret,
241
+ // Diagnostics only: it runs after the batch is already accepted, so
242
+ // nothing it does can turn a successful ingest into a failure.
243
+ onAccepted: { [acceptedAppId] appId in acceptedAppId.set(appId) })
244
+
245
+ let engine = AdvenueEngine(
246
+ config: EngineConfig(
247
+ apiKey: config.apiKey, platform: "ios", deviceId: deviceId,
248
+ installationId: installationId, appVersion: config.appVersion,
249
+ osVersion: osVersion,
250
+ sdkVersion: String((config.sdkVersion ?? AdvenueVersion.current).prefix(32)),
251
+ requireConsent: config.requireConsent, sessionWindowMs: config.sessionWindowMs,
252
+ batchSize: config.batchSize),
253
+ store: store, clock: SystemClock(), scheduler: TimerScheduler(), uuid: uuid,
254
+ transport: eventTransport,
255
+ onError: config.onError)
256
+ let pipe = CommandPipe(engine: engine, onError: config.onError)
257
+
258
+ // Armed only when there are rules to evaluate. The reporter is injectable
259
+ // for the same reason the transport is: an unwired one is invisible, and
260
+ // that shape has already cost this SDK three defects.
261
+ // SKAN is armed from the config the SERVER serves, not from one baked into
262
+ // the app: a conversion schema is tuned constantly and an app release cycle
263
+ // is weeks, so a config that can only change by shipping a binary is a
264
+ // config nobody changes. The app-supplied one stays as an offline default.
265
+ //
266
+ // The cached config arms SKAN synchronously through the ordered pipe, so an
267
+ // event tracked immediately after initialize is measured. The network fetch
268
+ // then re-arms if the server has something newer — a fetch raced against
269
+ // those first events would silently drop them.
270
+ let cached = SkanConfigCache.load(store)
271
+ let reporter = skanReporter ?? StoreKitSkanReporter(onError: config.onError)
272
+
273
+ if let initial = chooseSkanConfig(
274
+ fetched: nil, cached: cached, fallback: config.conversionValues),
275
+ let mapper = try? ConversionValueMapper(initial.rules)
276
+ {
277
+ pipe.submit(
278
+ .enableSkan(
279
+ mapper: mapper, currency: initial.rules.revenueCurrency,
280
+ installationId: installationId, reporter: reporter,
281
+ configVersion: initial.version))
282
+ }
283
+
284
+ if skanReporter == nil {
285
+ let fetcher = HttpSkanConfigFetcher(endpoint: config.endpoint, apiKey: config.apiKey)
286
+ Task {
287
+ let fetched = try? await fetcher.fetch(etag: cached?.etag)
288
+ guard let chosen = chooseSkanConfig(
289
+ fetched: fetched, cached: cached, fallback: config.conversionValues),
290
+ chosen.version != cached?.version || cached == nil,
291
+ let mapper = try? ConversionValueMapper(chosen.rules)
292
+ else { return }
293
+ SkanConfigCache.save(chosen, to: store)
294
+ pipe.submit(
295
+ .enableSkan(
296
+ mapper: mapper, currency: chosen.rules.revenueCurrency,
297
+ installationId: installationId, reporter: reporter,
298
+ configVersion: chosen.version))
299
+ }
300
+ }
301
+
302
+ lock.lock()
303
+ self.store = store
304
+ self.secure = secure
305
+ // Seeded from the same persisted values the engine loads, so consent
306
+ // granted in a previous run is still granted after a cold start.
307
+ self.consentMirror = store.string(forKey: CONSENT_KEY) == "granted"
308
+ self.consentDataMirror = readPersistedConsentData(store)
309
+ self.pipe = pipe
310
+ self.resolvedDeviceId = deviceId
311
+ let buffered = pendingDeepLinks
312
+ pendingDeepLinks = []
313
+ lock.unlock()
314
+
315
+ // Replayed in arrival order, before the first session, so a deferred deep
316
+ // link is attributed to the launch it belongs to.
317
+ for url in buffered { send(url) }
318
+ pipe.submit(.foreground)
319
+ // Seeded to match: the line above IS this launch's foreground, so the
320
+ // activation notification that follows must not open a second session.
321
+ observeLifecycle(seededInForeground: true)
322
+
323
+ if config.flushIntervalMs > 0 {
324
+ let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility))
325
+ timer.schedule(
326
+ deadline: .now() + .milliseconds(config.flushIntervalMs),
327
+ repeating: .milliseconds(config.flushIntervalMs))
328
+ timer.setEventHandler { [weak self] in self?.submit(.flush) }
329
+ timer.resume()
330
+ lock.lock()
331
+ flushTimer = timer
332
+ lock.unlock()
333
+ }
334
+
335
+ // Enrichment, then the install, off the caller's thread. `initialize`
336
+ // returns synchronously — an SDK that blocks
337
+ // didFinishLaunchingWithOptions for three seconds is one nobody ships.
338
+ let installSources =
339
+ sources
340
+ ?? EnrichmentSources.system(
341
+ appInstanceIdProvider: currentAppInstanceIdProvider,
342
+ attestation: {
343
+ // Two round trips, both best-effort: a device that cannot attest, a
344
+ // challenge the server would not issue, or an attestKey failure all
345
+ // yield nil and the install ships without the fields. An install held
346
+ // for attestation is an install lost.
347
+ let attestor = DeviceCheckAttestation(secure: secure)
348
+ let challenges = HttpChallengeFetcher(
349
+ endpoint: config.endpoint, apiKey: config.apiKey)
350
+ guard let challenge = try? await challenges.challenge(deviceId: deviceId),
351
+ let result = try? await attestor.attest(challenge: challenge)
352
+ else { return nil }
353
+ return (challenge: challenge, result: result)
354
+ })
355
+ Task { [weak self] in
356
+ let enrichment = await collectEnrichment(installSources, deadlineMs: INSTALL_WINDOW_MS)
357
+ self?.submit(
358
+ .setIdentity(
359
+ idfa: enrichment.idfa, vendorId: enrichment.vendorId,
360
+ appInstanceId: enrichment.appInstanceId))
361
+ self?.submit(.setDeviceInfo(collectDeviceInfo()))
362
+ // The CMP writes TCF to the standard defaults, and reading it is the
363
+ // difference between shipping a real consent signal and shipping none.
364
+ // Submitted before the install so the first event carries it.
365
+ if let consent = readTcf() { self?.submit(.setConsentData(consent)) }
366
+ self?.submit(
367
+ .trackInstall(
368
+ adservicesToken: enrichment.adservicesToken,
369
+ attestation: enrichment.attestation,
370
+ attestationChallenge: enrichment.attestationChallenge))
371
+ self?.submit(.flush)
372
+ }
373
+ }
374
+
375
+ private var currentAppInstanceIdProvider: (@Sendable () async -> String?)? {
376
+ lock.lock()
377
+ defer { lock.unlock() }
378
+ return appInstanceIdProvider
379
+ }
380
+
381
+ func submit(_ command: Command) {
382
+ lock.lock()
383
+ let pipe = self.pipe
384
+ lock.unlock()
385
+ pipe?.submit(command)
386
+ }
387
+
388
+ /// Erasure spans BOTH stores. The engine clears what lives in UserDefaults;
389
+ /// the durable device id lives in the Keychain and is wiped here. Calling
390
+ /// only the engine's `forgetMe` is the layering trap the spec recorded — an
391
+ /// erasure that leaves the identifier behind, and looks finished.
392
+ func forgetMe() {
393
+ submit(.forgetMe)
394
+ lock.lock()
395
+ // Erasure clears the read caches too: a getter still answering "granted"
396
+ // after forgetMe would report a consent the device no longer holds.
397
+ consentMirror = false
398
+ consentDataMirror = nil
399
+ let secure = self.secure
400
+ let store = self.store
401
+ resolvedDeviceId = nil
402
+ lock.unlock()
403
+ secure?.delete(DEVICE_ID_KEY)
404
+ store?.removeObject(forKey: INSTALLATION_ID_KEY)
405
+ store?.removeObject(forKey: INSTALL_SENT_KEY)
406
+ }
407
+
408
+ /// Synchronous internally: NSLock cannot be held across an async boundary,
409
+ /// and there is nothing to await yet. The PUBLIC accessor stays async
410
+ /// because deferred identity will eventually wait for first unlock, and
411
+ /// changing that signature later would break every caller.
412
+ var resolvedAppId: String? { acceptedAppId.current }
413
+
414
+ var trackingConsent: Bool {
415
+ lock.lock()
416
+ defer { lock.unlock() }
417
+ return consentMirror
418
+ }
419
+
420
+ var consentData: Consent? {
421
+ lock.lock()
422
+ defer { lock.unlock() }
423
+ return consentDataMirror
424
+ }
425
+
426
+ func rememberConsent(_ granted: Bool) {
427
+ lock.lock()
428
+ consentMirror = granted
429
+ lock.unlock()
430
+ }
431
+
432
+ func rememberConsentData(_ consent: Consent?) {
433
+ lock.lock()
434
+ consentDataMirror = consent
435
+ lock.unlock()
436
+ }
437
+
438
+ private func lookupTarget() -> (AdvenueConfig, String)? {
439
+ lock.lock()
440
+ defer { lock.unlock() }
441
+ guard let config = startedConfig, let deviceId = resolvedDeviceId else { return nil }
442
+ return (config, deviceId)
443
+ }
444
+
445
+ /// Polls the conversion lookup. Returns nil for an organic install, which is
446
+ /// most of them.
447
+ /// Named apart from the free function it calls, deliberately.
448
+ ///
449
+ /// It used to share that name and reach it through an `AdvenueCore.`
450
+ /// qualifier. That works here and breaks in the wrapper SDKs, which flatten
451
+ /// these modules into one — where the qualifier names nothing and dropping it
452
+ /// would call this method again, forever. A distinct name removes the trap
453
+ /// instead of relying on everyone remembering it.
454
+ func fetchDeferredDeepLink() async -> DeepLink? {
455
+ // Snapshot synchronously first: NSLock cannot be held across an await, and
456
+ // the same constraint shaped `currentDeviceId`.
457
+ guard let (config, deviceId) = lookupTarget() else { return nil }
458
+ return await resolveDeferredDeepLink(
459
+ fetcher: HttpConversionFetcher(
460
+ endpoint: config.endpoint, apiKey: config.apiKey, deviceId: deviceId))
461
+ }
462
+
463
+ var currentDeviceId: String? {
464
+ lock.lock()
465
+ defer { lock.unlock() }
466
+ return resolvedDeviceId
467
+ }
468
+
469
+ func setAppInstanceIdProvider(_ provider: @escaping @Sendable () async -> String?) {
470
+ lock.lock()
471
+ appInstanceIdProvider = provider
472
+ lock.unlock()
473
+ }
474
+
475
+ func deepLink(_ url: URL) {
476
+ lock.lock()
477
+ let started = pipe != nil
478
+ if !started { pendingDeepLinks.append(url) }
479
+ lock.unlock()
480
+ if started { send(url) }
481
+ }
482
+
483
+ /// Test surface: how many links are waiting for `initialize`.
484
+ var bufferedDeepLinkCount: Int {
485
+ lock.lock()
486
+ defer { lock.unlock() }
487
+ return pendingDeepLinks.count
488
+ }
489
+
490
+ private func send(_ url: URL) {
491
+ submit(
492
+ .track(
493
+ name: "deep_link", properties: ["url": .string(url.absoluteString)], type: "custom"))
494
+
495
+ // Meta AEM: a link from Meta carries al_applink_data with an opaque,
496
+ // Meta-encrypted campaign_ids blob. Emitted at most once per URL — the same
497
+ // link re-opened is not a second measurement, and the server dedups on this
498
+ // hash too.
499
+ guard
500
+ let applink = URLComponents(url: url, resolvingAgainstBaseURL: false)?
501
+ .queryItems?.first(where: { $0.name == "al_applink_data" })?.value,
502
+ let campaignIds = extractAemCampaignIds(applink)
503
+ else { return }
504
+
505
+ let hash = sha256Hex(url.absoluteString)
506
+ lock.lock()
507
+ let fresh = seenAemUrlHashes.insert(hash).inserted
508
+ lock.unlock()
509
+ guard fresh else { return }
510
+
511
+ submit(
512
+ .track(
513
+ name: "adv_meta_aem",
514
+ properties: ["campaignIds": .string(campaignIds), "sourceUrlHash": .string(hash)],
515
+ type: "custom"))
516
+ }
517
+
518
+ /// Subscribes to the app's own lifecycle, so an integrator does not have to.
519
+ ///
520
+ /// The Android SDK has always done this through `ActivityLifecycleCallbacks`;
521
+ /// iOS did not, and the asymmetry had no stated reason. Its cost was real: a
522
+ /// native app opened one session at launch and then never another, because a
523
+ /// return from background after the session window went unnoticed — and
524
+ /// backgrounding neither closed the session nor flushed, so a batch could sit
525
+ /// on the device until the next launch.
526
+ ///
527
+ /// `NotificationCenter` rather than `UIApplication.shared`, deliberately:
528
+ /// the notification names are plain constants, while `shared` is unavailable
529
+ /// in an app extension and merely referencing it there fails to link.
530
+ private func observeLifecycle(seededInForeground: Bool) {
531
+ // Seeded OUTSIDE the UIKit guard, deliberately. The tracker's state is
532
+ // platform-independent and must be right even where no observer can be
533
+ // registered — a macOS test process, for one, which is exactly where
534
+ // leaving it inside the guard made a signal read as a fresh foreground.
535
+ lock.lock()
536
+ foreground = ForegroundTracker(inForeground: seededInForeground)
537
+ lock.unlock()
538
+
539
+ #if canImport(UIKit)
540
+ let signals: [(Notification.Name, LifecycleSignal)] = [
541
+ (UIApplication.didBecomeActiveNotification, .didBecomeActive),
542
+ (UIApplication.willResignActiveNotification, .willResignActive),
543
+ (UIApplication.didEnterBackgroundNotification, .didEnterBackground),
544
+ ]
545
+ var registered: [NSObjectProtocol] = []
546
+ for (name, signal) in signals {
547
+ registered.append(
548
+ NotificationCenter.default.addObserver(
549
+ forName: name, object: nil, queue: nil
550
+ ) { [weak self] _ in
551
+ self?.handle(signal)
552
+ })
553
+ }
554
+ lock.lock()
555
+ lifecycleObservers = registered
556
+ lock.unlock()
557
+ #endif
558
+ }
559
+
560
+ /// Applies one lifecycle signal. Public routing lives here rather than in the
561
+ /// observer closure so a test can drive it without UIKit.
562
+ func handle(_ signal: LifecycleSignal) {
563
+ lock.lock()
564
+ let transition = foreground.on(signal)
565
+ lock.unlock()
566
+ switch transition {
567
+ case .none:
568
+ return
569
+ case .enteredForeground:
570
+ submit(.foreground)
571
+ case .enteredBackground:
572
+ // Backgrounding both closes the session and flushes: a batch stranded at
573
+ // the moment the app leaves the foreground may not be sent for hours.
574
+ submit(.background)
575
+ submit(.flush)
576
+ }
577
+ }
578
+
579
+ func stop() {
580
+ lock.lock()
581
+ let pipe = self.pipe
582
+ let timer = flushTimer
583
+ let observers = lifecycleObservers
584
+ self.pipe = nil
585
+ flushTimer = nil
586
+ lifecycleObservers = []
587
+ lock.unlock()
588
+ // Cancel before the pipe shuts down: a timer firing into a finished stream
589
+ // is harmless, but leaving it running leaks a repeating source per
590
+ // initialize() call. The observers go for the same reason, one instance
591
+ // further out.
592
+ timer?.cancel()
593
+ for observer in observers { NotificationCenter.default.removeObserver(observer) }
594
+ pipe?.shutdown()
595
+ }
596
+ }
@@ -0,0 +1,77 @@
1
+ import Foundation
2
+
3
+ public struct AdvenueConfig: Sendable {
4
+ public var apiKey: String
5
+ public var endpoint: String
6
+ public var appVersion: String?
7
+ public var requireConsent: Bool
8
+ public var sessionWindowMs: Int64
9
+ /// Events per request. Matches sdk-core.
10
+ public var batchSize: Int
11
+ /// Auto-flush period. Zero disables the timer, which is what tests want and
12
+ /// no shipping app does.
13
+ public var flushIntervalMs: Int
14
+
15
+ /// Per-key HMAC secret. **Read this before setting it.** The signature
16
+ /// provides integrity and replay protection, not authentication: anything
17
+ /// shipped inside an app binary can be extracted from it, exactly as it can
18
+ /// from a JavaScript bundle. Leaving it nil sends unsigned requests, which
19
+ /// the server accepts unless the app enforces signatures.
20
+ public var signingSecret: String?
21
+
22
+ /// SKAdNetwork conversion values. Absent means SKAN is not armed at all —
23
+ /// there is nothing to report without rules, so the machine is not built
24
+ /// rather than built and idle.
25
+ public var conversionValues: ConversionValueConfig?
26
+
27
+ /// Called when the SDK swallows a best-effort failure. Never receives PII.
28
+ public var onError: @Sendable (String, any Error) -> Void
29
+
30
+ /// Overrides the version stamped on every event. Set by a WRAPPER SDK, never
31
+ /// by an app.
32
+ ///
33
+ /// An event stamped with the Swift SDK's own version says the same thing for
34
+ /// every install and answers nothing. The useful answer is which wrapper
35
+ /// produced it — a React Native or Flutter release pins the native snapshot
36
+ /// inside it, so the wrapper's version identifies both, and wrapper-specific
37
+ /// bugs are the ones that need identifying. Adjust and AppsFlyer report the
38
+ /// wrapper for the same reason.
39
+ ///
40
+ /// Capped at 32 characters by the ingest schema; a longer value would take
41
+ /// the whole batch down with a 400, so it is truncated rather than sent.
42
+ public var sdkVersion: String?
43
+
44
+ public init(
45
+ apiKey: String,
46
+ endpoint: String = DEFAULT_ENDPOINT,
47
+ appVersion: String? = nil,
48
+ requireConsent: Bool = false,
49
+ sessionWindowMs: Int64 = DEFAULT_SESSION_WINDOW_MS,
50
+ batchSize: Int = 20,
51
+ flushIntervalMs: Int = 15_000,
52
+ signingSecret: String? = nil,
53
+ conversionValues: ConversionValueConfig? = nil,
54
+ onError: @escaping @Sendable (String, any Error) -> Void = { _, _ in },
55
+ sdkVersion: String? = nil
56
+ ) {
57
+ self.apiKey = apiKey
58
+ self.endpoint = endpoint
59
+ self.appVersion = appVersion
60
+ self.requireConsent = requireConsent
61
+ self.sessionWindowMs = sessionWindowMs
62
+ self.batchSize = batchSize
63
+ self.flushIntervalMs = flushIntervalMs
64
+ self.signingSecret = signingSecret
65
+ self.conversionValues = conversionValues
66
+ self.onError = onError
67
+ self.sdkVersion = sdkVersion
68
+ }
69
+ }
70
+
71
+ /// Stamped on every event as `sdkVersion`. Swift has no runtime access to its
72
+ /// package version, so this is a constant — and a constant drifts from the
73
+ /// release tag unless something checks. CI does, because the first question
74
+ /// every field report raises is which build produced the event.
75
+ public enum AdvenueVersion {
76
+ public static let current = "1.0.0"
77
+ }