@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,141 @@
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
+ /** Ignored: the SDK reads the app version from the package itself. Setting it reports `config.ignored:appVersion` through [onError]. Removed in the next major. */
16
+ val appVersion: String? = null,
17
+ val requireConsent: Boolean = false,
18
+ val sessionWindowMs: Long = DEFAULT_SESSION_WINDOW_MS,
19
+ val batchSize: Int = 20,
20
+ /** Auto-flush period. Zero disables the timer, which is what tests want and no
21
+ * shipping app does. */
22
+ val flushIntervalMs: Long = 15_000,
23
+ /**
24
+ * Per-key HMAC secret. **Read this before setting it.** The signature provides
25
+ * integrity and replay protection, not authentication: anything shipped inside
26
+ * an app binary can be extracted from it, exactly as it can from a JavaScript
27
+ * bundle. Leaving it null sends unsigned requests, which the server accepts
28
+ * unless the app enforces signatures.
29
+ */
30
+ val signingSecret: String? = null,
31
+ /** Called when the SDK swallows a best-effort failure. Never receives PII. */
32
+ val onError: (String, Throwable) -> Unit = { _, _ -> },
33
+ /**
34
+ * Overrides the version stamped on every event. Set by a WRAPPER SDK, never
35
+ * by an app.
36
+ *
37
+ * An event stamped with this SDK's own version says the same thing for every
38
+ * install and answers nothing. The useful answer is which wrapper produced it
39
+ * — a React Native or Flutter release pins the native snapshot inside it, so
40
+ * the wrapper's version identifies both. Adjust and AppsFlyer report the
41
+ * wrapper for the same reason.
42
+ *
43
+ * Capped at 32 characters by the ingest schema; a longer value would take the
44
+ * whole batch down with a 400, so it is truncated rather than sent.
45
+ */
46
+ val sdkVersion: String? = null,
47
+ /**
48
+ * Meta app id from the Meta App Dashboard, enabling the Meta install
49
+ * referrer. Not a secret — the decryption key stays server-side and the
50
+ * device only ever carries ciphertext. Leave it null and no Meta content
51
+ * provider is queried at all.
52
+ *
53
+ * Android only: the Meta install referrer is a content-provider read, and
54
+ * iOS has no equivalent. A field on the Swift config would do nothing.
55
+ */
56
+ val fbAppId: String? = null,
57
+ /**
58
+ * B1: track properties PII scrub'u. Varsayılan-açık; yalnızca açıkça
59
+ * kapatın (dokümante risk — ham PII ingest'e gider). Swift ile aynı.
60
+ */
61
+ val piiScrubEnabled: Boolean = true,
62
+ /**
63
+ * B5: testler için `http://` izni. Üretimde kapalı tutun — açık `http://`
64
+ * API anahtarı dahil tüm olayları şifresiz taşır.
65
+ */
66
+ val allowInsecureHttp: Boolean = false,
67
+ ) {
68
+ init {
69
+ // B5: şemasız endpoint fail-fast — geçersiz şema transportta değil,
70
+ // kurulumda yakalanır.
71
+ require(allowInsecureHttp || endpoint.startsWith("https://")) {
72
+ "Advenue endpoint must use https:// (set allowInsecureHttp only for tests)"
73
+ }
74
+ }
75
+ /**
76
+ * Java-friendly builder. Kotlin's named and default arguments are invisible
77
+ * from Java, and both incumbents are Java-first — a Kotlin SDK that is painful
78
+ * from Java excludes a large share of Android apps.
79
+ */
80
+ public class Builder(private val apiKey: String) {
81
+ private var config = AdvenueConfig(apiKey)
82
+
83
+ public fun endpoint(value: String): Builder = apply { config = config.copy(endpoint = value) }
84
+
85
+ /** Ignored — see [AdvenueConfig.appVersion]. */
86
+ public fun appVersion(value: String?): Builder = apply {
87
+ config = config.copy(appVersion = value)
88
+ }
89
+
90
+ public fun requireConsent(value: Boolean): Builder = apply {
91
+ config = config.copy(requireConsent = value)
92
+ }
93
+
94
+ public fun sessionWindowMs(value: Long): Builder = apply {
95
+ config = config.copy(sessionWindowMs = value)
96
+ }
97
+
98
+ public fun batchSize(value: Int): Builder = apply { config = config.copy(batchSize = value) }
99
+
100
+ public fun flushIntervalMs(value: Long): Builder = apply {
101
+ config = config.copy(flushIntervalMs = value)
102
+ }
103
+
104
+ public fun signingSecret(value: String?): Builder = apply {
105
+ config = config.copy(signingSecret = value)
106
+ }
107
+
108
+ public fun onError(value: (String, Throwable) -> Unit): Builder = apply {
109
+ config = config.copy(onError = value)
110
+ }
111
+
112
+ public fun sdkVersion(value: String?): Builder = apply {
113
+ config = config.copy(sdkVersion = value)
114
+ }
115
+
116
+ public fun fbAppId(value: String?): Builder = apply {
117
+ config = config.copy(fbAppId = value)
118
+ }
119
+
120
+ public fun piiScrubEnabled(value: Boolean): Builder = apply {
121
+ config = config.copy(piiScrubEnabled = value)
122
+ }
123
+
124
+ public fun allowInsecureHttp(value: Boolean): Builder = apply {
125
+ config = config.copy(allowInsecureHttp = value)
126
+ }
127
+
128
+ public fun build(): AdvenueConfig = config
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Stamped on every event as `sdkVersion`. An Android library has no runtime
134
+ * access to its own Gradle version without reflection, so this is a constant —
135
+ * and a constant drifts from the release tag unless something checks. CI does,
136
+ * because the first question every field report raises is which build produced
137
+ * the event.
138
+ */
139
+ public object AdvenueVersion {
140
+ public const val CURRENT: String = "0.1.0"
141
+ }
@@ -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
+ }
@@ -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,193 @@
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
+ /** B2: reklam kimliği rıza bayrağı — toplanıp düşürülüyordu. */
23
+ val limitAdTracking: Boolean? = null,
24
+ ) : Command()
25
+
26
+ internal data class SetAppInstanceId(val id: String?) : Command()
27
+
28
+ public data class TrackInstall(val properties: Map<String, Any?>?) : Command()
29
+
30
+ internal data class SetConsentData(val consent: Consent?) : Command()
31
+
32
+ internal data class SetPushToken(val token: String?, val provider: String?) : Command()
33
+
34
+ public object ForgetMe : Command()
35
+
36
+ public object Foreground : Command()
37
+
38
+ public object Background : Command()
39
+
40
+ public object Flush : Command()
41
+
42
+ /** Posted back by the io thread; carries what the network said. */
43
+ internal data class BatchOutcome(val error: Throwable?) : Command()
44
+
45
+ internal data class SingleOutcome(val event: ClientEvent, val error: Throwable?) : Command()
46
+
47
+ /** Deferred work hopped back onto the engine's thread — see TimerScheduler. */
48
+ internal data class RunScheduled(val work: () -> Unit) : Command()
49
+
50
+ internal object Shutdown : Command()
51
+ }
52
+
53
+ /**
54
+ * The ordered ingress: a synchronous, non-blocking [submit] feeding one
55
+ * consumer thread.
56
+ *
57
+ * Why a queue and not a thread per call: a thread pool does not preserve
58
+ * submission order, so an event could overtake its own `session_start` and be
59
+ * attributed to the wrong session. Adding to a `LinkedBlockingQueue` is
60
+ * non-blocking and ordered, which is exactly the property `AsyncStream.yield`
61
+ * gives the Swift port.
62
+ *
63
+ * **Two threads, on purpose.** `advenue-core` owns all state; `advenue-io`
64
+ * performs the transport call. In Swift, `flush` awaited inside an actor and
65
+ * reentrancy let other work proceed. A single blocking thread has no such
66
+ * property: a 15-second HTTP timeout would block every lifecycle command queued
67
+ * behind it.
68
+ */
69
+ internal class CommandPipe(
70
+ private val engine: AdvenueEngine,
71
+ private val onError: (String, Throwable) -> Unit = { _, _ -> },
72
+ ) {
73
+ /** A send the io thread must perform, and the events it covers. */
74
+ private class Send(val events: List<ClientEvent>, val single: ClientEvent?)
75
+
76
+ private val commands = LinkedBlockingQueue<Command>()
77
+ private val sends = LinkedBlockingQueue<Send>()
78
+ private val running = AtomicBoolean(true)
79
+
80
+ private val core =
81
+ thread("advenue-core") {
82
+ while (running.get()) {
83
+ val command = commands.take()
84
+ if (command === Command.Shutdown) break
85
+ // Each command is handled inside its own try/catch. An uncaught throw
86
+ // would END this thread: the queue would stop draining, submit() would
87
+ // keep accepting, nothing would send, nothing would error, and the app
88
+ // would not crash — attribution would simply stop. Silent total failure
89
+ // is the worst mode available, so this loop must be unkillable.
90
+ try {
91
+ handle(command)
92
+ } catch (error: Throwable) {
93
+ onError("engine.command", error)
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Performs network calls and nothing else. It never touches engine state: the
100
+ * decision of what to send and what to do with the answer stays on the core
101
+ * thread, which is what keeps the queue confined to one thread while still
102
+ * letting a 15-second request run without blocking anything.
103
+ */
104
+ private val io =
105
+ thread("advenue-io") {
106
+ while (running.get()) {
107
+ val send = sends.take()
108
+ if (!running.get()) break
109
+ val error = engine.send(send.events)
110
+ commands.put(
111
+ if (send.single != null) {
112
+ Command.SingleOutcome(send.single, error)
113
+ } else {
114
+ Command.BatchOutcome(error)
115
+ },
116
+ )
117
+ }
118
+ }
119
+
120
+ private fun handle(command: Command) {
121
+ when (command) {
122
+ is Command.Track -> engine.track(command.name, command.properties, command.type)
123
+ is Command.SetConsent -> engine.setConsent(command.granted)
124
+ is Command.SetUserId -> engine.setUserId(command.id)
125
+ is Command.SetIdentity ->
126
+ engine.setIdentity(
127
+ command.gaid,
128
+ command.androidId,
129
+ command.appInstanceId,
130
+ limitAdTracking = command.limitAdTracking,
131
+ )
132
+ is Command.SetAppInstanceId -> engine.setAppInstanceId(command.id)
133
+ is Command.TrackInstall -> engine.trackInstall(command.properties)
134
+ is Command.SetConsentData -> engine.setConsentData(command.consent)
135
+ is Command.SetPushToken -> engine.setPushToken(command.token, command.provider)
136
+ Command.ForgetMe -> engine.forgetMe()
137
+ Command.Foreground -> engine.notifyForeground()
138
+ Command.Background -> engine.notifyBackground()
139
+ Command.Flush -> engine.beginFlush()?.let { sends.put(Send(it, single = null)) }
140
+ is Command.BatchOutcome ->
141
+ engine.onBatchOutcome(command.error)?.let { sends.put(Send(listOf(it), single = it)) }
142
+ is Command.SingleOutcome ->
143
+ engine.onSingleOutcome(command.event, command.error)?.let {
144
+ sends.put(Send(listOf(it), single = it))
145
+ }
146
+ is Command.RunScheduled -> command.work()
147
+ Command.Shutdown -> Unit
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Synchronous, non-blocking and **ordered** — the entire reason the ingress
153
+ * is a queue.
154
+ */
155
+ public fun submit(command: Command) {
156
+ if (!running.get()) return
157
+ commands.put(command)
158
+ }
159
+
160
+ public fun shutdown() {
161
+ if (!running.compareAndSet(true, false)) return
162
+ commands.put(Command.Shutdown)
163
+ sends.put(Send(emptyList(), single = null))
164
+ }
165
+
166
+ /** Test surface: block until both queues are empty or the deadline passes. */
167
+ internal fun drain(timeoutMs: Long = 2_000) {
168
+ val deadline = System.currentTimeMillis() + timeoutMs
169
+ while (
170
+ (commands.isNotEmpty() || sends.isNotEmpty()) && System.currentTimeMillis() < deadline
171
+ ) {
172
+ Thread.sleep(5)
173
+ }
174
+ Thread.sleep(20)
175
+ }
176
+
177
+ private fun thread(name: String, body: () -> Unit): Thread =
178
+ Thread(
179
+ {
180
+ try {
181
+ body()
182
+ } catch (_: InterruptedException) {
183
+ // shutdown
184
+ }
185
+ },
186
+ name,
187
+ )
188
+ .apply {
189
+ // Daemon: the SDK must never hold a process open.
190
+ isDaemon = true
191
+ start()
192
+ }
193
+ }
@@ -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
+ }