@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,136 @@
1
+ package io.advenue.core
2
+
3
+ /**
4
+ * The wire envelope, mirroring `clientEventSchema` in
5
+ * `packages/shared/src/events.ts` and `ClientEvent` in the Swift port.
6
+ *
7
+ * Every optional is genuinely optional on the wire: the server uses zod
8
+ * `.optional()`, which accepts a missing key and **rejects** an explicit
9
+ * `null`. [writeCanonicalJson] omits null object entries, which is exactly the
10
+ * behaviour required — a serialiser emitting `null` would 400 the whole batch,
11
+ * every event in it.
12
+ */
13
+ internal data class ClientEvent(
14
+ public val id: String,
15
+ public val deviceId: String,
16
+ public val type: String,
17
+ public val name: String,
18
+ public val timestamp: String,
19
+ public val platform: String,
20
+ public val installationId: String? = null,
21
+ public val osVersion: String? = null,
22
+ public val appVersion: String? = null,
23
+ public val sdkVersion: String? = null,
24
+ public val network: String? = null,
25
+ public val campaign: String? = null,
26
+ public val country: String? = null,
27
+ public val idfa: String? = null,
28
+ public val gaid: String? = null,
29
+ public val limitAdTracking: Boolean? = null,
30
+ public val vendorId: String? = null,
31
+ public val androidId: String? = null,
32
+ public val customerUserId: String? = null,
33
+ public val appInstanceId: String? = null,
34
+ public val consent: Map<String, Any?>? = null,
35
+ public val properties: Map<String, Any?>? = null,
36
+ public val pushToken: String? = null,
37
+ public val pushProvider: String? = null,
38
+ public val attestationToken: String? = null,
39
+ public val attestationType: String? = null,
40
+ public val attestationChallenge: String? = null,
41
+ public val adservicesToken: String? = null,
42
+ ) {
43
+ internal fun toMap(): Map<String, Any?> =
44
+ mapOf(
45
+ "id" to id,
46
+ "deviceId" to deviceId,
47
+ "type" to type,
48
+ "name" to name,
49
+ "timestamp" to timestamp,
50
+ "platform" to platform,
51
+ "installationId" to installationId,
52
+ "osVersion" to osVersion,
53
+ "appVersion" to appVersion,
54
+ "sdkVersion" to sdkVersion,
55
+ "network" to network,
56
+ "campaign" to campaign,
57
+ "country" to country,
58
+ "idfa" to idfa,
59
+ "gaid" to gaid,
60
+ "limitAdTracking" to limitAdTracking,
61
+ "vendorId" to vendorId,
62
+ "androidId" to androidId,
63
+ "customerUserId" to customerUserId,
64
+ "appInstanceId" to appInstanceId,
65
+ "consent" to consent,
66
+ "properties" to properties,
67
+ "pushToken" to pushToken,
68
+ "pushProvider" to pushProvider,
69
+ "attestationToken" to attestationToken,
70
+ "attestationType" to attestationType,
71
+ "attestationChallenge" to attestationChallenge,
72
+ "adservicesToken" to adservicesToken,
73
+ )
74
+
75
+ /** The exact bytes this event puts on the wire. */
76
+ public fun encodeCanonical(): String = writeCanonicalJson(toMap())
77
+
78
+ public companion object {
79
+ /**
80
+ * The queue's reload path — and `properties` is restored with everything
81
+ * else, which it was not.
82
+ *
83
+ * It used to be dropped, on the premise that "a reloaded event is only ever
84
+ * re-sent as the bytes already written". The bytes are not re-sent: the
85
+ * object is. `EventQueue` assigns `events = load(store)` and `peek()`
86
+ * returns from `events`, so a reloaded event is exactly what the transport
87
+ * re-encodes. An app killed before flushing therefore sent every buffered
88
+ * event with no payload — no revenue, no currency, no sku. The writer had
89
+ * always written them; only this refused to read them.
90
+ *
91
+ * A queue conformance vector now pins it in both SDKs at once.
92
+ */
93
+ internal fun fromMap(map: Map<*, *>): ClientEvent? {
94
+ fun str(key: String): String? = map[key] as? String
95
+ val id = str("id") ?: return null
96
+ val deviceId = str("deviceId") ?: return null
97
+ val type = str("type") ?: return null
98
+ val name = str("name") ?: return null
99
+ val timestamp = str("timestamp") ?: return null
100
+ val platform = str("platform") ?: return null
101
+ return ClientEvent(
102
+ id = id,
103
+ deviceId = deviceId,
104
+ type = type,
105
+ name = name,
106
+ timestamp = timestamp,
107
+ platform = platform,
108
+ installationId = str("installationId"),
109
+ osVersion = str("osVersion"),
110
+ appVersion = str("appVersion"),
111
+ sdkVersion = str("sdkVersion"),
112
+ network = str("network"),
113
+ campaign = str("campaign"),
114
+ country = str("country"),
115
+ idfa = str("idfa"),
116
+ gaid = str("gaid"),
117
+ limitAdTracking = map["limitAdTracking"] as? Boolean,
118
+ vendorId = str("vendorId"),
119
+ androidId = str("androidId"),
120
+ customerUserId = str("customerUserId"),
121
+ appInstanceId = str("appInstanceId"),
122
+ // parseJson already yields the nested Map/List shape this field holds,
123
+ // so there is nothing to convert — only to stop discarding.
124
+ properties = (map["properties"] as? Map<*, *>)?.entries?.associate {
125
+ (it.key as String) to it.value
126
+ },
127
+ pushToken = str("pushToken"),
128
+ pushProvider = str("pushProvider"),
129
+ attestationToken = str("attestationToken"),
130
+ attestationType = str("attestationType"),
131
+ attestationChallenge = str("attestationChallenge"),
132
+ adservicesToken = str("adservicesToken"),
133
+ )
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,186 @@
1
+ package io.advenue.core
2
+
3
+ import java.util.concurrent.LinkedBlockingQueue
4
+ import java.util.concurrent.atomic.AtomicBoolean
5
+
6
+ /** A command submitted to the engine through the ordered ingress. */
7
+ internal sealed class Command {
8
+ public data class Track(
9
+ val name: String,
10
+ val properties: Map<String, Any?>?,
11
+ val type: String,
12
+ ) : Command()
13
+
14
+ public data class SetConsent(val granted: Boolean) : Command()
15
+
16
+ public data class SetUserId(val id: String?) : Command()
17
+
18
+ public data class SetIdentity(
19
+ val gaid: String?,
20
+ val androidId: String?,
21
+ val appInstanceId: String?,
22
+ ) : Command()
23
+
24
+ internal data class SetAppInstanceId(val id: String?) : Command()
25
+
26
+ public data class TrackInstall(val properties: Map<String, Any?>?) : Command()
27
+
28
+ internal data class SetConsentData(val consent: Consent?) : Command()
29
+
30
+ internal data class SetPushToken(val token: String?, val provider: String?) : Command()
31
+
32
+ public object ForgetMe : Command()
33
+
34
+ public object Foreground : Command()
35
+
36
+ public object Background : Command()
37
+
38
+ public object Flush : Command()
39
+
40
+ /** Posted back by the io thread; carries what the network said. */
41
+ internal data class BatchOutcome(val error: Throwable?) : Command()
42
+
43
+ internal data class SingleOutcome(val event: ClientEvent, val error: Throwable?) : Command()
44
+
45
+ /** Deferred work hopped back onto the engine's thread — see TimerScheduler. */
46
+ internal data class RunScheduled(val work: () -> Unit) : Command()
47
+
48
+ internal object Shutdown : Command()
49
+ }
50
+
51
+ /**
52
+ * The ordered ingress: a synchronous, non-blocking [submit] feeding one
53
+ * consumer thread.
54
+ *
55
+ * Why a queue and not a thread per call: a thread pool does not preserve
56
+ * submission order, so an event could overtake its own `session_start` and be
57
+ * attributed to the wrong session. Adding to a `LinkedBlockingQueue` is
58
+ * non-blocking and ordered, which is exactly the property `AsyncStream.yield`
59
+ * gives the Swift port.
60
+ *
61
+ * **Two threads, on purpose.** `advenue-core` owns all state; `advenue-io`
62
+ * performs the transport call. In Swift, `flush` awaited inside an actor and
63
+ * reentrancy let other work proceed. A single blocking thread has no such
64
+ * property: a 15-second HTTP timeout would block every lifecycle command queued
65
+ * behind it.
66
+ */
67
+ internal class CommandPipe(
68
+ private val engine: AdvenueEngine,
69
+ private val onError: (String, Throwable) -> Unit = { _, _ -> },
70
+ ) {
71
+ /** A send the io thread must perform, and the events it covers. */
72
+ private class Send(val events: List<ClientEvent>, val single: ClientEvent?)
73
+
74
+ private val commands = LinkedBlockingQueue<Command>()
75
+ private val sends = LinkedBlockingQueue<Send>()
76
+ private val running = AtomicBoolean(true)
77
+
78
+ private val core =
79
+ thread("advenue-core") {
80
+ while (running.get()) {
81
+ val command = commands.take()
82
+ if (command === Command.Shutdown) break
83
+ // Each command is handled inside its own try/catch. An uncaught throw
84
+ // would END this thread: the queue would stop draining, submit() would
85
+ // keep accepting, nothing would send, nothing would error, and the app
86
+ // would not crash — attribution would simply stop. Silent total failure
87
+ // is the worst mode available, so this loop must be unkillable.
88
+ try {
89
+ handle(command)
90
+ } catch (error: Throwable) {
91
+ onError("engine.command", error)
92
+ }
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Performs network calls and nothing else. It never touches engine state: the
98
+ * decision of what to send and what to do with the answer stays on the core
99
+ * thread, which is what keeps the queue confined to one thread while still
100
+ * letting a 15-second request run without blocking anything.
101
+ */
102
+ private val io =
103
+ thread("advenue-io") {
104
+ while (running.get()) {
105
+ val send = sends.take()
106
+ if (!running.get()) break
107
+ val error = engine.send(send.events)
108
+ commands.put(
109
+ if (send.single != null) {
110
+ Command.SingleOutcome(send.single, error)
111
+ } else {
112
+ Command.BatchOutcome(error)
113
+ },
114
+ )
115
+ }
116
+ }
117
+
118
+ private fun handle(command: Command) {
119
+ when (command) {
120
+ is Command.Track -> engine.track(command.name, command.properties, command.type)
121
+ is Command.SetConsent -> engine.setConsent(command.granted)
122
+ is Command.SetUserId -> engine.setUserId(command.id)
123
+ is Command.SetIdentity ->
124
+ engine.setIdentity(command.gaid, command.androidId, command.appInstanceId)
125
+ is Command.SetAppInstanceId -> engine.setAppInstanceId(command.id)
126
+ is Command.TrackInstall -> engine.trackInstall(command.properties)
127
+ is Command.SetConsentData -> engine.setConsentData(command.consent)
128
+ is Command.SetPushToken -> engine.setPushToken(command.token, command.provider)
129
+ Command.ForgetMe -> engine.forgetMe()
130
+ Command.Foreground -> engine.notifyForeground()
131
+ Command.Background -> engine.notifyBackground()
132
+ Command.Flush -> engine.beginFlush()?.let { sends.put(Send(it, single = null)) }
133
+ is Command.BatchOutcome ->
134
+ engine.onBatchOutcome(command.error)?.let { sends.put(Send(listOf(it), single = it)) }
135
+ is Command.SingleOutcome ->
136
+ engine.onSingleOutcome(command.event, command.error)?.let {
137
+ sends.put(Send(listOf(it), single = it))
138
+ }
139
+ is Command.RunScheduled -> command.work()
140
+ Command.Shutdown -> Unit
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Synchronous, non-blocking and **ordered** — the entire reason the ingress
146
+ * is a queue.
147
+ */
148
+ public fun submit(command: Command) {
149
+ if (!running.get()) return
150
+ commands.put(command)
151
+ }
152
+
153
+ public fun shutdown() {
154
+ if (!running.compareAndSet(true, false)) return
155
+ commands.put(Command.Shutdown)
156
+ sends.put(Send(emptyList(), single = null))
157
+ }
158
+
159
+ /** Test surface: block until both queues are empty or the deadline passes. */
160
+ internal fun drain(timeoutMs: Long = 2_000) {
161
+ val deadline = System.currentTimeMillis() + timeoutMs
162
+ while (
163
+ (commands.isNotEmpty() || sends.isNotEmpty()) && System.currentTimeMillis() < deadline
164
+ ) {
165
+ Thread.sleep(5)
166
+ }
167
+ Thread.sleep(20)
168
+ }
169
+
170
+ private fun thread(name: String, body: () -> Unit): Thread =
171
+ Thread(
172
+ {
173
+ try {
174
+ body()
175
+ } catch (_: InterruptedException) {
176
+ // shutdown
177
+ }
178
+ },
179
+ name,
180
+ )
181
+ .apply {
182
+ // Daemon: the SDK must never hold a process open.
183
+ isDaemon = true
184
+ start()
185
+ }
186
+ }
@@ -0,0 +1,52 @@
1
+ package io.advenue.core
2
+
3
+ /**
4
+ * Granular ad-platform consent (Google DMA), forwarded to Google and Meta by
5
+ * server-side postbacks as `gdpr_applies`, `ad_user_data`, `ad_personalization`
6
+ * and `ad_storage`.
7
+ *
8
+ * Three of the four fields are optional, and that is the point rather than
9
+ * laxity: **"not stated" is not "denied".** A CMP that has not asked about ad
10
+ * storage yet must not have `false` invented on its behalf, because downstream
11
+ * that is a recorded refusal. The canonical writer omits nulls, which is what
12
+ * keeps the distinction on the wire.
13
+ */
14
+ // Public because the facade both takes and returns it: an app that calls
15
+ // setConsentData must be able to read back what it set, and the iOS SDK's
16
+ // Consent is public for the same reason.
17
+ public data class Consent(
18
+ val isUserSubjectToGDPR: Boolean,
19
+ val hasConsentForDataUsage: Boolean? = null,
20
+ val hasConsentForAdsPersonalization: Boolean? = null,
21
+ val hasConsentForAdStorage: Boolean? = null,
22
+ ) {
23
+ internal fun toMap(): Map<String, Any?> =
24
+ mapOf(
25
+ "isUserSubjectToGDPR" to isUserSubjectToGDPR,
26
+ "hasConsentForDataUsage" to hasConsentForDataUsage,
27
+ "hasConsentForAdsPersonalization" to hasConsentForAdsPersonalization,
28
+ "hasConsentForAdStorage" to hasConsentForAdStorage,
29
+ )
30
+
31
+ internal companion object {
32
+ internal fun fromMap(map: Map<*, *>): Consent? {
33
+ val gdpr = map["isUserSubjectToGDPR"] as? Boolean ?: return null
34
+ return Consent(
35
+ isUserSubjectToGDPR = gdpr,
36
+ hasConsentForDataUsage = map["hasConsentForDataUsage"] as? Boolean,
37
+ hasConsentForAdsPersonalization = map["hasConsentForAdsPersonalization"] as? Boolean,
38
+ hasConsentForAdStorage = map["hasConsentForAdStorage"] as? Boolean,
39
+ )
40
+ }
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Validates a push token against the server's bound and charset.
46
+ *
47
+ * Rejecting here costs one dropped registration; letting it through costs every
48
+ * event in the batch it would have ridden in, because ingest parses a batch as
49
+ * a whole.
50
+ */
51
+ internal fun isValidPushToken(token: String): Boolean =
52
+ token.isNotEmpty() && token.length <= 512 && token.all { it.isLetterOrDigit() || it in "_:.-" }
@@ -0,0 +1,80 @@
1
+ package io.advenue.core
2
+
3
+ /**
4
+ * Synchronous key/value storage. On Android this is a `SharedPreferences` file;
5
+ * in tests it is a map. Mirrors `StorageAdapter` in sdk-core and `KeyValueStore`
6
+ * in the Swift port.
7
+ */
8
+ internal interface KeyValueStore {
9
+ public fun getString(key: String): String?
10
+
11
+ public fun setString(key: String, value: String)
12
+
13
+ public fun remove(key: String)
14
+ }
15
+
16
+ /** Milliseconds since the Unix epoch. Injected so vectors are deterministic. */
17
+ internal interface Clock {
18
+ public fun nowMs(): Long
19
+ }
20
+
21
+ /** Opaque handle returned by [Scheduler.schedule]. */
22
+ internal typealias CancelToken = Long
23
+
24
+ /**
25
+ * Deferred execution, injected so the queue's persist debounce is observable
26
+ * without waiting for it.
27
+ *
28
+ * **Isolation contract.** The scheduled work touches state confined to the
29
+ * engine's thread, so an implementation must run it on that same thread. The
30
+ * production scheduler satisfies this by posting back onto the command pipe;
31
+ * the test scheduler satisfies it by running inline.
32
+ */
33
+ internal interface Scheduler {
34
+ public fun schedule(afterMs: Int, work: () -> Unit): CancelToken
35
+
36
+ public fun cancel(token: CancelToken)
37
+ }
38
+
39
+ /** Event, session and device id source. Injected for the same reason. */
40
+ internal interface UuidSource {
41
+ public fun next(): String
42
+ }
43
+
44
+ /**
45
+ * HMAC-SHA256. An interface rather than a bare function so tests can pin the
46
+ * digest, but unlike the Swift port there is only ever one implementation:
47
+ * `javax.crypto` is in every JDK and every Android runtime, so the core can own
48
+ * it without taking a dependency.
49
+ */
50
+ internal interface Signer {
51
+ /** Lowercase hex digest of [message] under [secret], both UTF-8. */
52
+ public fun hmacSha256Hex(secret: String, message: String): String
53
+ }
54
+
55
+ /**
56
+ * HMAC over `"{timestamp}.{body}"` — the Stripe-style construction in
57
+ * `packages/signing`. Binding the timestamp is what makes replay detectable, so
58
+ * the separator and the order are part of the contract, not a detail.
59
+ */
60
+ internal fun signRequest(
61
+ signer: Signer,
62
+ secret: String,
63
+ timestamp: String,
64
+ body: String,
65
+ ): String = signer.hmacSha256Hex(secret, "$timestamp.$body")
66
+
67
+ /**
68
+ * What the core needs from a transport, and no more.
69
+ *
70
+ * Declared in the core rather than the platform layer because the flush loop is
71
+ * behaviour the `flush/` conformance vectors bind, and a rule the vectors cannot
72
+ * reach is a rule three implementations can each interpret differently.
73
+ */
74
+ internal interface EventTransport {
75
+ /**
76
+ * Delivers one batch. Throws [IngestError] on a non-2xx response, and on a
77
+ * transport failure, which maps to 408.
78
+ */
79
+ public fun send(events: List<ClientEvent>)
80
+ }
@@ -0,0 +1,69 @@
1
+ package io.advenue.core
2
+
3
+ /**
4
+ * Deferred deep linking: the install that came from a link carrying a
5
+ * destination, where the destination has to survive the trip through the store.
6
+ *
7
+ * The device asks the server what it was attributed to. An organic install is
8
+ * never attributed, so the lookup is *expected* to come back pending forever for
9
+ * most devices — which is why the loop gives up rather than polling on.
10
+ */
11
+ public data class DeepLink(
12
+ val deepLinkValue: String? = null,
13
+ val campaign: String? = null,
14
+ val network: String? = null,
15
+ val influencerId: String? = null,
16
+ val params: Map<String, Any?>? = null,
17
+ val isDeferred: Boolean = true,
18
+ val isFirstLaunch: Boolean = true,
19
+ )
20
+
21
+ /** What a lookup returned: still deciding, decided, or refused. */
22
+ public sealed class ConversionResult {
23
+ public object Pending : ConversionResult()
24
+
25
+ public data class Resolved(val link: DeepLink) : ConversionResult()
26
+ }
27
+
28
+ public interface ConversionFetcher {
29
+ /** Throws [IngestError] on a non-2xx; [IngestError.isRetryable] decides. */
30
+ public fun fetch(): ConversionResult
31
+ }
32
+
33
+ /** Copied from `packages/sdk-core/src/conversion.ts`, not chosen here. */
34
+ public val DEFAULT_CONVERSION_BACKOFF_MS: List<Long> = listOf(500L, 1_000L, 2_000L, 4_000L)
35
+
36
+ /**
37
+ * Polls the conversion lookup until it resolves, the budget runs out, or the
38
+ * server says never.
39
+ *
40
+ * The three exits are deliberately different. A **pending** answer means the
41
+ * pipeline has not decided yet, so it is worth waiting. A **non-retryable**
42
+ * error is the server saying it never will, and retrying it is pure battery
43
+ * cost on a device that will never get an answer. A **transient** error is
44
+ * indistinguishable from pending, so it falls through to the same backoff.
45
+ */
46
+ public fun resolveDeferredDeepLink(
47
+ fetcher: ConversionFetcher,
48
+ maxAttempts: Int = 5,
49
+ backoffMs: List<Long> = DEFAULT_CONVERSION_BACKOFF_MS,
50
+ sleep: (Long) -> Unit = { Thread.sleep(it) },
51
+ ): DeepLink? {
52
+ for (attempt in 0 until maxAttempts) {
53
+ try {
54
+ when (val result = fetcher.fetch()) {
55
+ is ConversionResult.Resolved -> return result.link
56
+ ConversionResult.Pending -> Unit
57
+ }
58
+ } catch (error: Throwable) {
59
+ val ingest = error as? IngestError
60
+ if (ingest != null && !ingest.isRetryable) return null
61
+ // Retryable, or a transport failure: fall through to the backoff.
62
+ }
63
+
64
+ if (attempt < maxAttempts - 1) {
65
+ sleep(backoffMs.getOrElse(attempt) { backoffMs.lastOrNull() ?: 0L })
66
+ }
67
+ }
68
+ return null
69
+ }