@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,632 @@
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
+ appVersion readVersion: () -> String? = readAppVersion
213
+ ) {
214
+ // Replace-and-shut-down, never add.
215
+ stop()
216
+
217
+ // The SDK resolves the app version itself (readAppVersion); a config value
218
+ // is ignored — and said so, rather than looking like it took effect.
219
+ if config.appVersion != nil {
220
+ config.onError(
221
+ "config.ignored:appVersion",
222
+ IngestError(status: 0))
223
+ }
224
+
225
+ // B4: kuyruk blob'u Caches dosyalarına (yedek dışı); UserDefaults'ta
226
+ // kalan eski blob ilk okumada migrate edilir. Caches yoksa UserDefaults
227
+ // geri dönüşüdür — yedek kapsamı açılır ama SDK çalışır (fail-open, loglanır).
228
+ let store: any KeyValueStore
229
+ if let files = CacheFileStore.caches() {
230
+ store = CompositeStore(files: files)
231
+ } else {
232
+ store = UserDefaultsStore()
233
+ config.onError("store.cache_unavailable", IngestError(status: 0))
234
+ }
235
+ let secure = KeychainStore()
236
+ let uuid = SystemUUIDs()
237
+
238
+ let identity = resolveIdentity(secure: secure, store: store, uuid: uuid)
239
+ guard case .resolved(let deviceId, let installationId) = identity else {
240
+ // Deferred: the Keychain could not be read. Nothing is minted and
241
+ // nothing starts, so no event carries an invented identifier. The next
242
+ // launch after first unlock resolves it.
243
+ config.onError("identity.deferred", IngestError(status: 0))
244
+ return
245
+ }
246
+
247
+ let osVersion: String?
248
+ #if canImport(UIKit)
249
+ osVersion = UIDevice.current.systemVersion
250
+ #else
251
+ osVersion = nil
252
+ #endif
253
+
254
+ let eventTransport =
255
+ transport
256
+ ?? HttpTransport(
257
+ endpoint: config.endpoint, apiKey: config.apiKey,
258
+ signingSecret: config.signingSecret,
259
+ // Diagnostics only: it runs after the batch is already accepted, so
260
+ // nothing it does can turn a successful ingest into a failure.
261
+ onAccepted: { [acceptedAppId] appId in acceptedAppId.set(appId) })
262
+
263
+ let engine = AdvenueEngine(
264
+ config: EngineConfig(
265
+ apiKey: config.apiKey, platform: "ios", deviceId: deviceId,
266
+ installationId: installationId, appVersion: readVersion(),
267
+ osVersion: osVersion,
268
+ sdkVersion: String((config.sdkVersion ?? AdvenueVersion.current).prefix(32)),
269
+ requireConsent: config.requireConsent, sessionWindowMs: config.sessionWindowMs,
270
+ batchSize: config.batchSize),
271
+ store: store, clock: SystemClock(), scheduler: TimerScheduler(), uuid: uuid,
272
+ transport: eventTransport,
273
+ onError: config.onError)
274
+ let pipe = CommandPipe(engine: engine, onError: config.onError)
275
+
276
+ // Armed only when there are rules to evaluate. The reporter is injectable
277
+ // for the same reason the transport is: an unwired one is invisible, and
278
+ // that shape has already cost this SDK three defects.
279
+ // SKAN is armed from the config the SERVER serves, not from one baked into
280
+ // the app: a conversion schema is tuned constantly and an app release cycle
281
+ // is weeks, so a config that can only change by shipping a binary is a
282
+ // config nobody changes. The app-supplied one stays as an offline default.
283
+ //
284
+ // The cached config arms SKAN synchronously through the ordered pipe, so an
285
+ // event tracked immediately after initialize is measured. The network fetch
286
+ // then re-arms if the server has something newer — a fetch raced against
287
+ // those first events would silently drop them.
288
+ let cached = SkanConfigCache.load(store)
289
+ let reporter = skanReporter ?? StoreKitSkanReporter(onError: config.onError)
290
+
291
+ if let initial = chooseSkanConfig(
292
+ fetched: nil, cached: cached, fallback: config.conversionValues),
293
+ let mapper = try? ConversionValueMapper(initial.rules)
294
+ {
295
+ pipe.submit(
296
+ .enableSkan(
297
+ mapper: mapper, currency: initial.rules.revenueCurrency,
298
+ installationId: installationId, reporter: reporter,
299
+ configVersion: initial.version))
300
+ }
301
+
302
+ if skanReporter == nil {
303
+ let fetcher = HttpSkanConfigFetcher(endpoint: config.endpoint, apiKey: config.apiKey)
304
+ Task {
305
+ let fetched = try? await fetcher.fetch(etag: cached?.etag)
306
+ guard let chosen = chooseSkanConfig(
307
+ fetched: fetched, cached: cached, fallback: config.conversionValues),
308
+ chosen.version != cached?.version || cached == nil,
309
+ let mapper = try? ConversionValueMapper(chosen.rules)
310
+ else { return }
311
+ SkanConfigCache.save(chosen, to: store)
312
+ pipe.submit(
313
+ .enableSkan(
314
+ mapper: mapper, currency: chosen.rules.revenueCurrency,
315
+ installationId: installationId, reporter: reporter,
316
+ configVersion: chosen.version))
317
+ }
318
+ }
319
+
320
+ lock.lock()
321
+ self.store = store
322
+ self.secure = secure
323
+ // Seeded from the same persisted values the engine loads, so consent
324
+ // granted in a previous run is still granted after a cold start.
325
+ self.consentMirror = store.string(forKey: CONSENT_KEY) == "granted"
326
+ self.consentDataMirror = readPersistedConsentData(store)
327
+ self.pipe = pipe
328
+ self.resolvedDeviceId = deviceId
329
+ let buffered = pendingDeepLinks
330
+ pendingDeepLinks = []
331
+ lock.unlock()
332
+
333
+ // Replayed in arrival order, before the first session, so a deferred deep
334
+ // link is attributed to the launch it belongs to.
335
+ for url in buffered { send(url) }
336
+ pipe.submit(.foreground)
337
+ // Seeded to match: the line above IS this launch's foreground, so the
338
+ // activation notification that follows must not open a second session.
339
+ observeLifecycle(seededInForeground: true)
340
+
341
+ if config.flushIntervalMs > 0 {
342
+ let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility))
343
+ timer.schedule(
344
+ deadline: .now() + .milliseconds(config.flushIntervalMs),
345
+ repeating: .milliseconds(config.flushIntervalMs))
346
+ timer.setEventHandler { [weak self] in self?.submit(.flush) }
347
+ timer.resume()
348
+ lock.lock()
349
+ flushTimer = timer
350
+ lock.unlock()
351
+ }
352
+
353
+ // Enrichment, then the install, off the caller's thread. `initialize`
354
+ // returns synchronously — an SDK that blocks
355
+ // didFinishLaunchingWithOptions for three seconds is one nobody ships.
356
+ let installSources =
357
+ sources
358
+ ?? EnrichmentSources.system(
359
+ appInstanceIdProvider: currentAppInstanceIdProvider,
360
+ attestation: {
361
+ // Two round trips, both best-effort: a device that cannot attest, a
362
+ // challenge the server would not issue, or an attestKey failure all
363
+ // yield nil and the install ships without the fields. An install held
364
+ // for attestation is an install lost.
365
+ let attestor = DeviceCheckAttestation(secure: secure)
366
+ let challenges = HttpChallengeFetcher(
367
+ endpoint: config.endpoint, apiKey: config.apiKey)
368
+ guard let challenge = try? await challenges.challenge(deviceId: deviceId),
369
+ let result = try? await attestor.attest(challenge: challenge)
370
+ else { return nil }
371
+ return (challenge: challenge, result: result)
372
+ })
373
+ Task { [weak self] in
374
+ let enrichment = await collectEnrichment(installSources, deadlineMs: INSTALL_WINDOW_MS)
375
+ self?.submit(
376
+ .setIdentity(
377
+ idfa: enrichment.idfa, vendorId: enrichment.vendorId,
378
+ appInstanceId: enrichment.appInstanceId,
379
+ limitAdTracking: enrichment.limitAdTracking))
380
+ self?.submit(.setDeviceInfo(collectDeviceInfo()))
381
+ // The CMP writes TCF to the standard defaults, and reading it is the
382
+ // difference between shipping a real consent signal and shipping none.
383
+ // Submitted before the install so the first event carries it.
384
+ if let consent = readTcf() { self?.submit(.setConsentData(consent)) }
385
+ self?.submit(
386
+ .trackInstall(
387
+ adservicesToken: enrichment.adservicesToken,
388
+ attestation: enrichment.attestation,
389
+ attestationChallenge: enrichment.attestationChallenge))
390
+ self?.submit(.flush)
391
+ }
392
+ }
393
+
394
+ private var currentAppInstanceIdProvider: (@Sendable () async -> String?)? {
395
+ lock.lock()
396
+ defer { lock.unlock() }
397
+ return appInstanceIdProvider
398
+ }
399
+
400
+ func submit(_ command: Command) {
401
+ lock.lock()
402
+ let pipe = self.pipe
403
+ lock.unlock()
404
+ pipe?.submit(command)
405
+ }
406
+
407
+ /// Erasure spans BOTH stores. The engine clears what lives in UserDefaults;
408
+ /// the durable device id lives in the Keychain and is wiped here. Calling
409
+ /// only the engine's `forgetMe` is the layering trap the spec recorded — an
410
+ /// erasure that leaves the identifier behind, and looks finished.
411
+ func forgetMe() {
412
+ submit(.forgetMe)
413
+ lock.lock()
414
+ // Erasure clears the read caches too: a getter still answering "granted"
415
+ // after forgetMe would report a consent the device no longer holds.
416
+ consentMirror = false
417
+ consentDataMirror = nil
418
+ let secure = self.secure
419
+ let store = self.store
420
+ resolvedDeviceId = nil
421
+ lock.unlock()
422
+ secure?.delete(DEVICE_ID_KEY)
423
+ store?.removeObject(forKey: INSTALLATION_ID_KEY)
424
+ store?.removeObject(forKey: INSTALL_SENT_KEY)
425
+ }
426
+
427
+ /// Synchronous internally: NSLock cannot be held across an async boundary,
428
+ /// and there is nothing to await yet. The PUBLIC accessor stays async
429
+ /// because deferred identity will eventually wait for first unlock, and
430
+ /// changing that signature later would break every caller.
431
+ var resolvedAppId: String? { acceptedAppId.current }
432
+
433
+ var trackingConsent: Bool {
434
+ lock.lock()
435
+ defer { lock.unlock() }
436
+ return consentMirror
437
+ }
438
+
439
+ var consentData: Consent? {
440
+ lock.lock()
441
+ defer { lock.unlock() }
442
+ return consentDataMirror
443
+ }
444
+
445
+ func rememberConsent(_ granted: Bool) {
446
+ lock.lock()
447
+ consentMirror = granted
448
+ lock.unlock()
449
+ }
450
+
451
+ func rememberConsentData(_ consent: Consent?) {
452
+ lock.lock()
453
+ consentDataMirror = consent
454
+ lock.unlock()
455
+ }
456
+
457
+ private func lookupTarget() -> (AdvenueConfig, String)? {
458
+ lock.lock()
459
+ defer { lock.unlock() }
460
+ guard let config = startedConfig, let deviceId = resolvedDeviceId else { return nil }
461
+ return (config, deviceId)
462
+ }
463
+
464
+ /// Polls the conversion lookup. Returns nil for an organic install, which is
465
+ /// most of them.
466
+ /// Named apart from the free function it calls, deliberately.
467
+ ///
468
+ /// It used to share that name and reach it through an `AdvenueCore.`
469
+ /// qualifier. That works here and breaks in the wrapper SDKs, which flatten
470
+ /// these modules into one — where the qualifier names nothing and dropping it
471
+ /// would call this method again, forever. A distinct name removes the trap
472
+ /// instead of relying on everyone remembering it.
473
+ func fetchDeferredDeepLink() async -> DeepLink? {
474
+ // Snapshot synchronously first: NSLock cannot be held across an await, and
475
+ // the same constraint shaped `currentDeviceId`.
476
+ guard let (config, deviceId) = lookupTarget() else { return nil }
477
+ return await resolveDeferredDeepLink(
478
+ fetcher: HttpConversionFetcher(
479
+ endpoint: config.endpoint, apiKey: config.apiKey, deviceId: deviceId))
480
+ }
481
+
482
+ var currentDeviceId: String? {
483
+ lock.lock()
484
+ defer { lock.unlock() }
485
+ return resolvedDeviceId
486
+ }
487
+
488
+ func setAppInstanceIdProvider(_ provider: @escaping @Sendable () async -> String?) {
489
+ lock.lock()
490
+ appInstanceIdProvider = provider
491
+ lock.unlock()
492
+ }
493
+
494
+ func deepLink(_ url: URL) {
495
+ lock.lock()
496
+ let started = pipe != nil
497
+ if !started { pendingDeepLinks.append(url) }
498
+ lock.unlock()
499
+ if started { send(url) }
500
+ }
501
+
502
+ /// Test surface: how many links are waiting for `initialize`.
503
+ var bufferedDeepLinkCount: Int {
504
+ lock.lock()
505
+ defer { lock.unlock() }
506
+ return pendingDeepLinks.count
507
+ }
508
+
509
+ /// B3: deep-link URL'sinden query+fragment atılır. URLComponents
510
+ /// çözümlenemezse ham dizenin `?`/`#` öncesi alınır (asla ham query sızmaz).
511
+ func stripUrlQuery(_ url: URL) -> String {
512
+ if var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
513
+ components.query = nil
514
+ components.fragment = nil
515
+ if let stripped = components.string { return stripped }
516
+ }
517
+ let raw = url.absoluteString
518
+ if let cut = raw.firstIndex(where: { $0 == "?" || $0 == "#" }) {
519
+ return String(raw[..<cut])
520
+ }
521
+ return raw
522
+ }
523
+
524
+ private func send(_ url: URL) {
525
+ // B3: ham query gönderilmez — token/e-posta query'de taşınır. AEM
526
+ // ayrıştırması ve hash TAM url'den yapılır (aşağıda).
527
+ submit(
528
+ .track(
529
+ name: "deep_link", properties: ["url": .string(stripUrlQuery(url))], type: "custom"))
530
+
531
+ // Meta AEM: a link from Meta carries al_applink_data with an opaque,
532
+ // Meta-encrypted campaign_ids blob. Emitted at most once per URL — the same
533
+ // link re-opened is not a second measurement, and the server dedups on this
534
+ // hash too.
535
+ guard
536
+ let applink = URLComponents(url: url, resolvingAgainstBaseURL: false)?
537
+ .queryItems?.first(where: { $0.name == "al_applink_data" })?.value,
538
+ let campaignIds = extractAemCampaignIds(applink)
539
+ else { return }
540
+
541
+ let hash = sha256Hex(url.absoluteString)
542
+ lock.lock()
543
+ let fresh = seenAemUrlHashes.insert(hash).inserted
544
+ lock.unlock()
545
+ guard fresh else { return }
546
+
547
+ submit(
548
+ .track(
549
+ name: "adv_meta_aem",
550
+ properties: ["campaignIds": .string(campaignIds), "sourceUrlHash": .string(hash)],
551
+ type: "custom"))
552
+ }
553
+
554
+ /// Subscribes to the app's own lifecycle, so an integrator does not have to.
555
+ ///
556
+ /// The Android SDK has always done this through `ActivityLifecycleCallbacks`;
557
+ /// iOS did not, and the asymmetry had no stated reason. Its cost was real: a
558
+ /// native app opened one session at launch and then never another, because a
559
+ /// return from background after the session window went unnoticed — and
560
+ /// backgrounding neither closed the session nor flushed, so a batch could sit
561
+ /// on the device until the next launch.
562
+ ///
563
+ /// `NotificationCenter` rather than `UIApplication.shared`, deliberately:
564
+ /// the notification names are plain constants, while `shared` is unavailable
565
+ /// in an app extension and merely referencing it there fails to link.
566
+ private func observeLifecycle(seededInForeground: Bool) {
567
+ // Seeded OUTSIDE the UIKit guard, deliberately. The tracker's state is
568
+ // platform-independent and must be right even where no observer can be
569
+ // registered — a macOS test process, for one, which is exactly where
570
+ // leaving it inside the guard made a signal read as a fresh foreground.
571
+ lock.lock()
572
+ foreground = ForegroundTracker(inForeground: seededInForeground)
573
+ lock.unlock()
574
+
575
+ #if canImport(UIKit)
576
+ let signals: [(Notification.Name, LifecycleSignal)] = [
577
+ (UIApplication.didBecomeActiveNotification, .didBecomeActive),
578
+ (UIApplication.willResignActiveNotification, .willResignActive),
579
+ (UIApplication.didEnterBackgroundNotification, .didEnterBackground),
580
+ ]
581
+ var registered: [NSObjectProtocol] = []
582
+ for (name, signal) in signals {
583
+ registered.append(
584
+ NotificationCenter.default.addObserver(
585
+ forName: name, object: nil, queue: nil
586
+ ) { [weak self] _ in
587
+ self?.handle(signal)
588
+ })
589
+ }
590
+ lock.lock()
591
+ lifecycleObservers = registered
592
+ lock.unlock()
593
+ #endif
594
+ }
595
+
596
+ /// Applies one lifecycle signal. Public routing lives here rather than in the
597
+ /// observer closure so a test can drive it without UIKit.
598
+ func handle(_ signal: LifecycleSignal) {
599
+ lock.lock()
600
+ let transition = foreground.on(signal)
601
+ lock.unlock()
602
+ switch transition {
603
+ case .none:
604
+ return
605
+ case .enteredForeground:
606
+ submit(.foreground)
607
+ case .enteredBackground:
608
+ // Backgrounding both closes the session and flushes: a batch stranded at
609
+ // the moment the app leaves the foreground may not be sent for hours.
610
+ submit(.background)
611
+ submit(.flush)
612
+ }
613
+ }
614
+
615
+ func stop() {
616
+ lock.lock()
617
+ let pipe = self.pipe
618
+ let timer = flushTimer
619
+ let observers = lifecycleObservers
620
+ self.pipe = nil
621
+ flushTimer = nil
622
+ lifecycleObservers = []
623
+ lock.unlock()
624
+ // Cancel before the pipe shuts down: a timer firing into a finished stream
625
+ // is harmless, but leaving it running leaks a repeating source per
626
+ // initialize() call. The observers go for the same reason, one instance
627
+ // further out.
628
+ timer?.cancel()
629
+ for observer in observers { NotificationCenter.default.removeObserver(observer) }
630
+ pipe?.shutdown()
631
+ }
632
+ }