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