@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,574 @@
1
+ package io.advenue
2
+
3
+ import android.app.Application
4
+ import android.content.BroadcastReceiver
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.content.IntentFilter
8
+ import android.os.Build
9
+ import io.advenue.core.AdvenueEngine
10
+ import io.advenue.core.Command
11
+ import io.advenue.core.CommandPipe
12
+ import io.advenue.core.Consent
13
+ import io.advenue.core.DeepLink
14
+ import io.advenue.core.resolveDeferredDeepLink
15
+ import io.advenue.core.EngineConfig
16
+ import io.advenue.core.EventTransport
17
+ import io.advenue.core.IngestError
18
+ import io.advenue.core.SystemClock
19
+ import io.advenue.core.SystemUuids
20
+ import io.advenue.core.TimerScheduler
21
+ import io.advenue.platform.HttpConversionFetcher
22
+ import io.advenue.platform.HttpUrlTransport
23
+ import io.advenue.platform.IdentityResolution
24
+ import io.advenue.platform.EnrichmentSources
25
+ import io.advenue.platform.InstallScopedStore
26
+ import io.advenue.platform.LifecycleBridge
27
+ import io.advenue.platform.CompositeStore
28
+ import io.advenue.platform.PreferencesStore
29
+ import io.advenue.core.extractAemCampaignIds
30
+ import io.advenue.core.tcfToConsent
31
+ import io.advenue.platform.collectDeviceInfo
32
+ import io.advenue.platform.readTcf
33
+ import io.advenue.platform.readAppVersion
34
+ import io.advenue.platform.collectEnrichment
35
+ import io.advenue.platform.sha256Hex
36
+ import io.advenue.platform.resolveIdentity
37
+ import io.advenue.platform.systemUnlockProbe
38
+ import java.util.Timer
39
+ import java.util.TimerTask
40
+
41
+ /**
42
+ * The public SDK. A static facade matching the React Native and iOS SDKs' names,
43
+ * so a developer moving between them reads the same API, delegating to state
44
+ * that is replaceable and inspectable rather than to a hidden singleton.
45
+ */
46
+ public object Advenue {
47
+ private val state = FacadeState()
48
+
49
+ /**
50
+ * Starts the SDK. Safe to call from `Application.onCreate`, and safe to call
51
+ * twice: the previous instance is shut down first. Without that, a second call
52
+ * would leave two consumer threads draining one queue, so events would be
53
+ * processed twice or lost.
54
+ *
55
+ * Takes the `Application` explicitly. ContentProvider auto-initialisation —
56
+ * the Firebase pattern — is rejected: it charges every cold start and hides
57
+ * initialisation order.
58
+ */
59
+ @JvmStatic
60
+ public fun initialize(application: Application, config: AdvenueConfig) {
61
+ state.start(application, config)
62
+ }
63
+
64
+ /** Records an event. Synchronous, non-blocking and ordered. */
65
+ @JvmStatic
66
+ @JvmOverloads
67
+ public fun track(name: String, properties: Map<String, Any?>? = null) {
68
+ state.submit(Command.Track(name, properties, "custom"))
69
+ }
70
+
71
+ @JvmStatic
72
+ public fun setUserId(id: String?) {
73
+ state.submit(Command.SetUserId(id))
74
+ }
75
+
76
+ /**
77
+ * Granular ad-platform consent (Google DMA), forwarded by server-side
78
+ * postbacks as gdpr_applies / ad_user_data / ad_personalization / ad_storage.
79
+ *
80
+ * Leave a field null when the user has not been asked: "not stated" is not
81
+ * "denied", and inventing false on their behalf records a refusal that never
82
+ * happened.
83
+ */
84
+ @JvmStatic
85
+ @JvmOverloads
86
+ public fun setConsentData(
87
+ isUserSubjectToGDPR: Boolean,
88
+ hasConsentForDataUsage: Boolean? = null,
89
+ hasConsentForAdsPersonalization: Boolean? = null,
90
+ hasConsentForAdStorage: Boolean? = null,
91
+ ) {
92
+ val consent =
93
+ Consent(
94
+ isUserSubjectToGDPR,
95
+ hasConsentForDataUsage,
96
+ hasConsentForAdsPersonalization,
97
+ hasConsentForAdStorage,
98
+ )
99
+ state.consentDataMirror = consent
100
+ state.submit(Command.SetConsentData(consent))
101
+ }
102
+
103
+ /**
104
+ * Registers the device's push token for uninstall measurement.
105
+ *
106
+ * Advenue never asks for the notification permission and never displays
107
+ * anything. Pass the token your push library already gives you, on every
108
+ * launch: the OS can rotate it, and a stale token is what makes uninstall
109
+ * measurement report churn that did not happen. Pass null to stop.
110
+ */
111
+ @JvmStatic
112
+ @JvmOverloads
113
+ public fun setPushToken(token: String?, provider: String? = null) {
114
+ state.submit(Command.SetPushToken(token, provider))
115
+ }
116
+
117
+ /**
118
+ * The app the ingestion service resolved this API key to, or null until a
119
+ * batch has been accepted.
120
+ *
121
+ * The API key is the SDK's entire app identity, so pasting the wrong one is
122
+ * silent: events are still accepted, just recorded against another app, and
123
+ * every screen the integrator checks is the one they believe they configured.
124
+ * This is the answer to "which app am I actually writing to", read from the
125
+ * device rather than inferred from the dashboard.
126
+ */
127
+ @JvmStatic public fun resolvedAppId(): String? = state.resolvedAppId
128
+
129
+ @JvmStatic
130
+ public fun setConsent(granted: Boolean) {
131
+ state.consentMirror = granted
132
+ state.submit(Command.SetConsent(granted))
133
+ }
134
+
135
+ /**
136
+ * Whether tracking consent is currently granted. Persisted, so this is the
137
+ * answer after a restart too — a caller that defaulted to false on every cold
138
+ * start would silently discard a preference the user gave.
139
+ */
140
+ @JvmStatic public fun trackingConsent(): Boolean = state.consentMirror
141
+
142
+ /** The granular DMA consent last set, or null if none has been. */
143
+ @JvmStatic public fun consentData(): Consent? = state.consentDataMirror
144
+
145
+ /**
146
+ * The Firebase App Instance ID, when the app resolves it itself rather than
147
+ * through the `advenue-firebase` plugin. Does not disturb the advertising
148
+ * identity.
149
+ */
150
+ @JvmStatic
151
+ public fun setAppInstanceId(id: String?) {
152
+ state.submit(Command.SetAppInstanceId(id))
153
+ }
154
+
155
+ /** Sends what is buffered. A no-op when the queue is empty or a backoff is open. */
156
+ @JvmStatic
157
+ public fun flush() {
158
+ state.submit(Command.Flush)
159
+ }
160
+
161
+ /**
162
+ * Forward from `onCreate` and `onNewIntent`. Callable **before** `initialize`:
163
+ * a cold start from a link runs the launcher activity first, and the links
164
+ * that carry attribution are precisely the ones that would be lost.
165
+ *
166
+ * `Intent.EXTRA_REFERRER` is deliberately not read here — it is not the Play
167
+ * install referrer, and conflating them would make a warm-start deep link look
168
+ * like one.
169
+ */
170
+ @JvmStatic
171
+ public fun processDeepLink(intent: Intent?) {
172
+ intent?.data?.toString()?.let { state.deepLink(it) }
173
+ }
174
+
175
+ /** Erasure. Spans both stores — see [FacadeState.forgetMe]. */
176
+ @JvmStatic
177
+ public fun forgetMe() {
178
+ state.forgetMe()
179
+ }
180
+
181
+ /**
182
+ * The device identifier, or **null** while identity is deferred.
183
+ *
184
+ * sdk-core's getter is non-null; this one is not, and that is a real API
185
+ * difference rather than an oversight. Identity can be deferred while
186
+ * credential-encrypted storage is unreadable (Direct Boot), and blocking until
187
+ * it resolves could block indefinitely. Returning an invented id is the
188
+ * phantom-device bug the design exists to prevent.
189
+ */
190
+ @JvmStatic public fun deviceId(): String? = state.currentDeviceId
191
+
192
+ @JvmStatic public fun pendingCount(): Int = state.pendingCount()
193
+
194
+ /**
195
+ * Resolves the deferred deep link for this install, or null for an organic
196
+ * one — which is most of them. Safe to call once on first launch; the app
197
+ * routes on the result.
198
+ *
199
+ * Blocking, and deliberately so: it polls with backoff, and hiding that
200
+ * behind a callback would invite calling it on the main thread. Run it on
201
+ * your own background thread.
202
+ */
203
+ @JvmStatic public fun resolveDeferredDeepLink(): DeepLink? = state.resolveDeferredDeepLink()
204
+
205
+ @JvmStatic
206
+ public fun shutdown() {
207
+ state.stop()
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Holds what a static facade cannot: the live engine, the command pipe, the
213
+ * pre-init deep-link buffer and the resolved identity.
214
+ */
215
+ internal class FacadeState {
216
+ private val lock = Any()
217
+ private var pipe: CommandPipe? = null
218
+ private var engine: AdvenueEngine? = null
219
+ private var scoped: InstallScopedStore? = null
220
+ private var prefs: PreferencesStore? = null
221
+ private var bridge: LifecycleBridge? = null
222
+ private var flushTimer: Timer? = null
223
+ private var unlockReceiver: BroadcastReceiver? = null
224
+ private var app: Application? = null
225
+ private var pendingDeepLinks = mutableListOf<String>()
226
+ private val seenAemUrlHashes = mutableSetOf<String>()
227
+
228
+ /**
229
+ * Read caches for the two consent values. The engine owns persistence; these
230
+ * exist so a synchronous getter can answer without a round trip through the
231
+ * pipe, and so a caller reading back its own `setConsent` never sees the
232
+ * value it just replaced.
233
+ */
234
+ @Volatile internal var consentMirror: Boolean = false
235
+
236
+ @Volatile internal var consentDataMirror: Consent? = null
237
+
238
+ @Volatile private var resolvedDeviceId: String? = null
239
+
240
+ /** Set by the transport after a batch is accepted; diagnostics only. */
241
+ @Volatile internal var resolvedAppId: String? = null
242
+ private var startedEndpoint: String? = null
243
+ private var startedApiKey: String? = null
244
+
245
+ /**
246
+ * `transport` is a parameter, not a hidden construction, because an unwired
247
+ * transport is otherwise invisible: every component of the send path can be
248
+ * green while nothing joins them. That is the defect the iOS SDK shipped with,
249
+ * and `FacadeSeamTest` injects a recorder here to make sure this one cannot.
250
+ */
251
+ fun start(
252
+ application: Application,
253
+ config: AdvenueConfig,
254
+ transport: EventTransport? = null,
255
+ sources: EnrichmentSources? = null,
256
+ ) {
257
+ // Replace-and-shut-down, never add.
258
+ stop()
259
+
260
+ // The SDK resolves the app version itself (readAppVersion); a config value
261
+ // is ignored — and said so, rather than looking like it took effect.
262
+ if (config.appVersion != null) {
263
+ config.onError(
264
+ "config.ignored:appVersion",
265
+ IllegalArgumentException("appVersion is resolved by the SDK and has no effect"),
266
+ )
267
+ }
268
+ val scoped = InstallScopedStore(application)
269
+ val prefs = PreferencesStore(application)
270
+ val uuid = SystemUuids()
271
+
272
+ val identity = resolveIdentity(scoped, systemUnlockProbe(application), uuid)
273
+ if (identity !is IdentityResolution.Resolved) {
274
+ // Deferred: credential-encrypted storage is unreadable. Nothing is minted
275
+ // and nothing starts, so no event carries an invented identifier. The
276
+ // receiver below is the exit — without it the deferral would trade a
277
+ // phantom id for permanent silence.
278
+ config.onError("identity.deferred", IngestError(0))
279
+ registerUnlockRetry(application, config, transport, sources)
280
+ return
281
+ }
282
+
283
+ val eventTransport =
284
+ transport
285
+ ?: HttpUrlTransport(
286
+ endpoint = config.endpoint,
287
+ apiKey = config.apiKey,
288
+ signingSecret = config.signingSecret,
289
+ clock = SystemClock(),
290
+ // Diagnostics only: it runs after the batch is already accepted, so
291
+ // nothing it does can turn a successful ingest into a failure.
292
+ onAccepted = { appId -> resolvedAppId = appId },
293
+ )
294
+
295
+ val scheduler = TimerScheduler()
296
+ val engine =
297
+ AdvenueEngine(
298
+ config =
299
+ EngineConfig(
300
+ apiKey = config.apiKey,
301
+ platform = "android",
302
+ deviceId = identity.deviceId,
303
+ installationId = identity.installationId,
304
+ appVersion = readAppVersion(application),
305
+ osVersion = Build.VERSION.RELEASE,
306
+ sdkVersion = (config.sdkVersion ?: AdvenueVersion.CURRENT).take(32),
307
+ requireConsent = config.requireConsent,
308
+ sessionWindowMs = config.sessionWindowMs,
309
+ batchSize = config.batchSize,
310
+ piiScrubEnabled = config.piiScrubEnabled,
311
+ ),
312
+ store = CompositeStore(prefs, scoped),
313
+ clock = SystemClock(),
314
+ scheduler = scheduler,
315
+ uuid = uuid,
316
+ transport = eventTransport,
317
+ onError = config.onError,
318
+ )
319
+ val pipe = CommandPipe(engine, config.onError)
320
+ // The debounce timer may only WAKE; the work itself touches the event list,
321
+ // which belongs to the engine's thread.
322
+ scheduler.dispatcher = { work -> pipe.submit(Command.RunScheduled(work)) }
323
+
324
+ val buffered: List<String>
325
+ synchronized(lock) {
326
+ this.app = application
327
+ this.scoped = scoped
328
+ this.prefs = prefs
329
+ this.engine = engine
330
+ // Seeded from the same persisted values the engine loads, so consent
331
+ // granted in a previous run is still granted after a cold start.
332
+ this.consentMirror = engine.getConsent()
333
+ this.consentDataMirror = engine.getConsentData()
334
+ this.pipe = pipe
335
+ this.resolvedDeviceId = identity.deviceId
336
+ this.startedEndpoint = config.endpoint
337
+ this.startedApiKey = config.apiKey
338
+ buffered = pendingDeepLinks.toList()
339
+ pendingDeepLinks = mutableListOf()
340
+ }
341
+
342
+ // Replayed in arrival order, before the first session, so a deferred deep
343
+ // link is attributed to the launch it belongs to.
344
+ buffered.forEach { sendDeepLink(it) }
345
+
346
+ val bridge =
347
+ LifecycleBridge(
348
+ application,
349
+ onForeground = { pipe.submit(Command.Foreground) },
350
+ onBackground = {
351
+ pipe.submit(Command.Background)
352
+ // A batch stranded when the app leaves the foreground may not be sent
353
+ // for hours.
354
+ pipe.submit(Command.Flush)
355
+ },
356
+ )
357
+ bridge.start()
358
+ // Application.onCreate runs before the first activity starts, so the
359
+ // foreground signal arrives from the bridge; a cold start where the SDK is
360
+ // initialised from an already-running activity still needs this.
361
+ pipe.submit(Command.Foreground)
362
+
363
+ // Enrichment then install, off the caller's thread. initialize() returns
364
+ // synchronously — an SDK that blocks Application.onCreate for five seconds
365
+ // is one nobody ships.
366
+ val installSources = sources ?: EnrichmentSources.discovered()
367
+ Thread(
368
+ {
369
+ val enrichment =
370
+ collectEnrichment(
371
+ application,
372
+ identity.deviceId,
373
+ installSources,
374
+ fbAppId = config.fbAppId,
375
+ )
376
+ pipe.submit(
377
+ Command.SetIdentity(
378
+ gaid = enrichment.gaid,
379
+ androidId = null,
380
+ appInstanceId = enrichment.appInstanceId,
381
+ // B2: bayrak artık düşürülmüyor — olaya işlenir.
382
+ limitAdTracking = enrichment.limitAdTracking,
383
+ ),
384
+ )
385
+ // Device metadata rides the install, where Meta CAPI reads it.
386
+ // Merged under the referrer properties: an attribution field wins
387
+ // over a descriptive one if they ever collide.
388
+ val installProperties =
389
+ collectDeviceInfo(application) +
390
+ (enrichment.referrerProperties ?: emptyMap()) +
391
+ (enrichment.metaProperties ?: emptyMap())
392
+ // The CMP writes TCF to the platform's default preferences, and
393
+ // reading it is the difference between shipping a real consent
394
+ // signal and shipping none. Submitted before the install so the
395
+ // first event carries it.
396
+ readTcf(application)?.let { tcf ->
397
+ tcfToConsent(
398
+ tcf["gdprApplies"] as? Int,
399
+ tcf["purposeConsents"] as? String,
400
+ )
401
+ ?.let { pipe.submit(Command.SetConsentData(it)) }
402
+ }
403
+ pipe.submit(Command.TrackInstall(installProperties))
404
+ pipe.submit(Command.Flush)
405
+ },
406
+ "advenue-install",
407
+ )
408
+ .apply { isDaemon = true }
409
+ .start()
410
+
411
+ val timer =
412
+ if (config.flushIntervalMs > 0) {
413
+ Timer("advenue-flush", true).also {
414
+ it.scheduleAtFixedRate(
415
+ object : TimerTask() {
416
+ override fun run() {
417
+ pipe.submit(Command.Flush)
418
+ }
419
+ },
420
+ config.flushIntervalMs,
421
+ config.flushIntervalMs,
422
+ )
423
+ }
424
+ } else {
425
+ null
426
+ }
427
+
428
+ synchronized(lock) {
429
+ this.bridge = bridge
430
+ this.flushTimer = timer
431
+ }
432
+ }
433
+
434
+ /**
435
+ * The exit from a deferred identity. Registered only when identity could not
436
+ * be resolved; unregistered on the first successful start.
437
+ */
438
+ private fun registerUnlockRetry(
439
+ application: Application,
440
+ config: AdvenueConfig,
441
+ transport: EventTransport?,
442
+ sources: EnrichmentSources?,
443
+ ) {
444
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
445
+ val receiver =
446
+ object : BroadcastReceiver() {
447
+ override fun onReceive(context: Context, intent: Intent) {
448
+ runCatching { application.unregisterReceiver(this) }
449
+ synchronized(lock) { unlockReceiver = null }
450
+ start(application, config, transport, sources)
451
+ }
452
+ }
453
+ runCatching {
454
+ application.registerReceiver(receiver, IntentFilter(Intent.ACTION_USER_UNLOCKED))
455
+ synchronized(lock) { unlockReceiver = receiver }
456
+ }
457
+ }
458
+
459
+ fun submit(command: Command) {
460
+ synchronized(lock) { pipe }?.submit(command)
461
+ }
462
+
463
+ fun deepLink(url: String) {
464
+ val started = synchronized(lock) {
465
+ val running = pipe != null
466
+ if (!running) pendingDeepLinks.add(url)
467
+ running
468
+ }
469
+ if (started) sendDeepLink(url)
470
+ }
471
+
472
+ private fun sendDeepLink(url: String) {
473
+ // B3: ham query gönderilmez — token/e-posta query'de taşınır. AEM
474
+ // ayrıştırması ve sourceUrlHash TAM url'den yapılır (aşağıda), yalnızca
475
+ // gönderilen `url` özelliği scheme+host+path'e indirgenir.
476
+ submit(Command.Track("deep_link", mapOf("url" to stripUrlQuery(url)), "custom"))
477
+
478
+ // Meta AEM: a link from Meta carries al_applink_data with an opaque,
479
+ // Meta-encrypted campaign_ids blob. Emitted at most once per URL — the
480
+ // same link re-opened is not a second measurement, and the server dedups
481
+ // on this hash too.
482
+ val applink = queryValue(url, "al_applink_data") ?: return
483
+ val campaignIds = extractAemCampaignIds(applink) ?: return
484
+ val hash = sha256Hex(url)
485
+ val fresh = synchronized(lock) { seenAemUrlHashes.add(hash) }
486
+ if (!fresh) return
487
+ submit(
488
+ Command.Track(
489
+ "adv_meta_aem",
490
+ mapOf("campaignIds" to campaignIds, "sourceUrlHash" to hash),
491
+ "custom",
492
+ ),
493
+ )
494
+ }
495
+
496
+ /** Decoded value of one query parameter, or null. */
497
+ private fun queryValue(url: String, name: String): String? =
498
+ runCatching { android.net.Uri.parse(url).getQueryParameter(name) }.getOrNull()
499
+
500
+
501
+ /** Test surface: how many links are waiting for `initialize`. */
502
+ val bufferedDeepLinkCount: Int
503
+ get() = synchronized(lock) { pendingDeepLinks.size }
504
+
505
+ val currentDeviceId: String?
506
+ get() = resolvedDeviceId
507
+
508
+ fun pendingCount(): Int = synchronized(lock) { engine }?.pendingEventIds()?.size ?: 0
509
+
510
+ /** Polls the conversion lookup. Null for an organic install. */
511
+ fun resolveDeferredDeepLink(): DeepLink? {
512
+ val (endpoint, apiKey, deviceId) =
513
+ synchronized(lock) {
514
+ Triple(startedEndpoint, startedApiKey, resolvedDeviceId)
515
+ }
516
+ if (endpoint == null || apiKey == null || deviceId == null) return null
517
+ return resolveDeferredDeepLink(
518
+ HttpConversionFetcher(endpoint = endpoint, apiKey = apiKey, deviceId = deviceId))
519
+ }
520
+
521
+ /**
522
+ * Erasure spans BOTH stores. The engine clears what lives in
523
+ * SharedPreferences; the install-scoped identifiers live in
524
+ * `noBackupFilesDir` and are wiped here. Calling only the engine's `forgetMe`
525
+ * is the layering trap the spec recorded — an erasure that leaves the
526
+ * identifiers behind, and looks finished.
527
+ */
528
+ fun forgetMe() {
529
+ // Both stores are wiped by the engine, on the pipe's thread, so the wipe is
530
+ // ordered behind any command already queued — a wipe performed here would
531
+ // race a pending trackInstall and the flag would be written back after it.
532
+ submit(Command.ForgetMe)
533
+ synchronized(lock) { resolvedDeviceId = null }
534
+ // Erasure clears the read caches too: a getter still answering "granted"
535
+ // after forgetMe would report a consent the device no longer holds.
536
+ consentMirror = false
537
+ consentDataMirror = null
538
+ }
539
+
540
+ fun stop() {
541
+ val (pipe, bridge, timer, receiver, application) =
542
+ synchronized(lock) {
543
+ val snapshot =
544
+ Quintuple(this.pipe, this.bridge, this.flushTimer, this.unlockReceiver, this.app)
545
+ this.pipe = null
546
+ this.engine = null
547
+ this.bridge = null
548
+ this.flushTimer = null
549
+ this.unlockReceiver = null
550
+ snapshot
551
+ }
552
+ timer?.cancel()
553
+ bridge?.stop()
554
+ receiver?.let { r -> application?.let { runCatching { it.unregisterReceiver(r) } } }
555
+ pipe?.shutdown()
556
+ }
557
+
558
+ private data class Quintuple(
559
+ val pipe: CommandPipe?,
560
+ val bridge: LifecycleBridge?,
561
+ val timer: Timer?,
562
+ val receiver: BroadcastReceiver?,
563
+ val app: Application?,
564
+ )
565
+ }
566
+
567
+ /**
568
+ * B3: deep-link URL'sinden query+fragment atılır (token/e-posta taşınır).
569
+ * Saf dize işlemi — Android çalıştırıcısı gerekmez, JVM testinde de çalışır.
570
+ */
571
+ internal fun stripUrlQuery(url: String): String {
572
+ val cut = url.indexOfAny(charArrayOf('?', '#'))
573
+ return if (cut >= 0) url.substring(0, cut) else url
574
+ }