@advenue/react-native 0.8.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 -347
  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 -655
  39. package/dist/index.d.cts +199 -331
  40. package/dist/index.d.ts +199 -331
  41. package/dist/index.js +182 -661
  42. package/ios/AdvenueIosModule.swift +144 -432
  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 +267 -803
  88. package/src/native-types.ts +74 -171
  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,272 @@
1
+ package io.advenue.core
2
+
3
+ /**
4
+ * Canonical JSON, written by hand.
5
+ *
6
+ * Three reasons, each sufficient on its own. `org.json` is a stub in JVM unit
7
+ * tests — calling it throws unless the test is mocked or run under Robolectric.
8
+ * Gson, Moshi and kotlinx.serialization are each a dependency an SDK would push
9
+ * into the host app's version resolution, and kotlinx.serialization is also a
10
+ * compiler plugin. And none of the three guarantees key order, which the
11
+ * byte-exact `envelope/` conformance vectors require.
12
+ *
13
+ * A fourth reason emerged from writing it: no reflection, so consumers need no
14
+ * `keep` rules for model classes. Gson or Moshi would push those rules into
15
+ * every consumer's build.
16
+ */
17
+
18
+ /**
19
+ * Serialises [value] with sorted object keys, omitting null object entries.
20
+ *
21
+ * Accepted types are String, Boolean, Int, Long, Double, List and Map with
22
+ * String keys. Anything else throws, rather than being coerced to something
23
+ * plausible: a silently mangled property is worse than a rejected event,
24
+ * because it reaches the warehouse looking correct.
25
+ */
26
+ internal fun writeCanonicalJson(value: Any?): String {
27
+ val out = StringBuilder()
28
+ writeValue(value, out)
29
+ return out.toString()
30
+ }
31
+
32
+ private fun writeValue(value: Any?, out: StringBuilder) {
33
+ when (value) {
34
+ null -> out.append("null")
35
+ is String -> writeString(value, out)
36
+ is Boolean -> out.append(if (value) "true" else "false")
37
+ is Int -> out.append(value.toString())
38
+ is Long -> out.append(value.toString())
39
+ is Double -> out.append(formatDouble(value))
40
+ is Float -> out.append(formatDouble(value.toDouble()))
41
+ is Map<*, *> -> writeObject(value, out)
42
+ is List<*> -> {
43
+ out.append('[')
44
+ value.forEachIndexed { index, element ->
45
+ if (index > 0) out.append(',')
46
+ // A null inside an ARRAY has no key to omit, so it is serialised.
47
+ writeValue(element, out)
48
+ }
49
+ out.append(']')
50
+ }
51
+ else -> throw IllegalArgumentException(
52
+ "unencodable JSON value: ${value::class.java.simpleName}",
53
+ )
54
+ }
55
+ }
56
+
57
+ private fun writeObject(map: Map<*, *>, out: StringBuilder) {
58
+ out.append('{')
59
+ var first = true
60
+ // Sorted so output is byte-comparable with the conformance vectors and with
61
+ // what Swift's .sortedKeys and the TypeScript snapshots produce.
62
+ val keys = map.keys.map { it as? String ?: throw IllegalArgumentException("non-string JSON key") }
63
+ for (key in keys.sorted()) {
64
+ val entry = map[key]
65
+ // zod .optional() accepts a missing key and rejects an explicit null.
66
+ if (entry == null) continue
67
+ if (!first) out.append(',')
68
+ first = false
69
+ writeString(key, out)
70
+ out.append(':')
71
+ writeValue(entry, out)
72
+ }
73
+ out.append('}')
74
+ }
75
+
76
+ /**
77
+ * `JSON.stringify` prints an integral number without a decimal point: `1`, not
78
+ * `1.0`. Below 2^53 a double is exactly integral or it is not, so the check is
79
+ * safe; above it the plain representation is used, as JavaScript does.
80
+ */
81
+ private fun formatDouble(value: Double): String {
82
+ if (!value.isFinite()) throw IllegalArgumentException("non-finite JSON number: $value")
83
+ if (value == Math.floor(value) && Math.abs(value) < 9_007_199_254_740_992.0) {
84
+ return value.toLong().toString()
85
+ }
86
+ return value.toString()
87
+ }
88
+
89
+ private fun writeString(value: String, out: StringBuilder) {
90
+ out.append('"')
91
+ for (char in value) {
92
+ when (char) {
93
+ '"' -> out.append("\\\"")
94
+ '\\' -> out.append("\\\\")
95
+ '\b' -> out.append("\\b")
96
+ '\n' -> out.append("\\n")
97
+ '\r' -> out.append("\\r")
98
+ '\t' -> out.append("\\t")
99
+ '\u000C' -> out.append("\\f")
100
+ // A forward slash is NOT escaped: JSON.stringify leaves it, and Swift is
101
+ // configured with .withoutEscapingSlashes to match.
102
+ else ->
103
+ if (char < ' ') {
104
+ out.append(String.format("\\u%04x", char.code))
105
+ } else {
106
+ out.append(char)
107
+ }
108
+ }
109
+ }
110
+ out.append('"')
111
+ }
112
+
113
+ /**
114
+ * Parses JSON into Map/List/String/Boolean/Long/Double/null.
115
+ *
116
+ * Returns null on malformed input rather than throwing. This only ever reads
117
+ * data the SDK itself wrote — the persisted queue and session blobs — plus one
118
+ * diagnostic field from an ingest response, and a corrupt blob must cost the
119
+ * offline buffer rather than brick the SDK on every launch.
120
+ */
121
+ internal fun parseJson(text: String): Any? =
122
+ try {
123
+ val parser = JsonParser(text)
124
+ val value = parser.parseValue()
125
+ parser.skipWhitespace()
126
+ if (!parser.atEnd()) null else value
127
+ } catch (_: Exception) {
128
+ null
129
+ }
130
+
131
+ private class JsonParser(private val text: String) {
132
+ private var index = 0
133
+
134
+ fun atEnd(): Boolean = index >= text.length
135
+
136
+ fun skipWhitespace() {
137
+ while (index < text.length && text[index].isWhitespace()) index++
138
+ }
139
+
140
+ fun parseValue(): Any? {
141
+ skipWhitespace()
142
+ if (atEnd()) throw IllegalStateException("unexpected end")
143
+ return when (text[index]) {
144
+ '{' -> parseObject()
145
+ '[' -> parseArray()
146
+ '"' -> parseString()
147
+ 't' -> literal("true", true)
148
+ 'f' -> literal("false", false)
149
+ 'n' -> literal("null", null)
150
+ else -> parseNumber()
151
+ }
152
+ }
153
+
154
+ private fun literal(token: String, value: Any?): Any? {
155
+ if (!text.startsWith(token, index)) throw IllegalStateException("bad literal")
156
+ index += token.length
157
+ return value
158
+ }
159
+
160
+ private fun parseObject(): Map<String, Any?> {
161
+ index++ // {
162
+ val result = LinkedHashMap<String, Any?>()
163
+ skipWhitespace()
164
+ if (text[index] == '}') {
165
+ index++
166
+ return result
167
+ }
168
+ while (true) {
169
+ skipWhitespace()
170
+ val key = parseString()
171
+ skipWhitespace()
172
+ if (text[index] != ':') throw IllegalStateException("expected :")
173
+ index++
174
+ result[key] = parseValue()
175
+ skipWhitespace()
176
+ when (text[index]) {
177
+ ',' -> index++
178
+ '}' -> {
179
+ index++
180
+ return result
181
+ }
182
+ else -> throw IllegalStateException("expected , or }")
183
+ }
184
+ }
185
+ }
186
+
187
+ private fun parseArray(): List<Any?> {
188
+ index++ // [
189
+ val result = ArrayList<Any?>()
190
+ skipWhitespace()
191
+ if (text[index] == ']') {
192
+ index++
193
+ return result
194
+ }
195
+ while (true) {
196
+ result.add(parseValue())
197
+ skipWhitespace()
198
+ when (text[index]) {
199
+ ',' -> index++
200
+ ']' -> {
201
+ index++
202
+ return result
203
+ }
204
+ else -> throw IllegalStateException("expected , or ]")
205
+ }
206
+ }
207
+ }
208
+
209
+ private fun parseString(): String {
210
+ if (text[index] != '"') throw IllegalStateException("expected string")
211
+ index++
212
+ val out = StringBuilder()
213
+ while (true) {
214
+ val char = text[index]
215
+ when {
216
+ char == '"' -> {
217
+ index++
218
+ return out.toString()
219
+ }
220
+ char == '\\' -> {
221
+ index++
222
+ when (val escape = text[index]) {
223
+ '"' -> out.append('"')
224
+ '\\' -> out.append('\\')
225
+ '/' -> out.append('/')
226
+ 'b' -> out.append('\b')
227
+ 'f' -> out.append('\u000C')
228
+ 'n' -> out.append('\n')
229
+ 'r' -> out.append('\r')
230
+ 't' -> out.append('\t')
231
+ 'u' -> {
232
+ out.append(text.substring(index + 1, index + 5).toInt(16).toChar())
233
+ index += 4
234
+ }
235
+ else -> throw IllegalStateException("bad escape $escape")
236
+ }
237
+ index++
238
+ }
239
+ else -> {
240
+ out.append(char)
241
+ index++
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ /**
248
+ * A number with no `.`, `e` or `E` parses as Long. That distinction is not
249
+ * cosmetic: session numbers and epoch timestamps are integers, and reloading
250
+ * them as doubles would rewrite the persisted blob with decimal points the
251
+ * TypeScript reader has never seen.
252
+ */
253
+ private fun parseNumber(): Any {
254
+ val start = index
255
+ if (text[index] == '-' || text[index] == '+') index++
256
+ var floating = false
257
+ while (index < text.length) {
258
+ val char = text[index]
259
+ if (char in '0'..'9') {
260
+ index++
261
+ } else if (char == '.' || char == 'e' || char == 'E' || char == '+' || char == '-') {
262
+ floating = floating || char == '.' || char == 'e' || char == 'E'
263
+ index++
264
+ } else {
265
+ break
266
+ }
267
+ }
268
+ val token = text.substring(start, index)
269
+ if (token.isEmpty()) throw IllegalStateException("expected number")
270
+ return if (floating) token.toDouble() else token.toLong()
271
+ }
272
+ }
@@ -0,0 +1,47 @@
1
+ package io.advenue.core
2
+
3
+ /**
4
+ * Client-side enforcement of the ingest schema's input budgets.
5
+ *
6
+ * Without this the SDK enqueues an event the server is guaranteed to reject,
7
+ * and the rejection is not per-event: `apps/ingestion/src/app.ts` parses the
8
+ * batch as a whole and answers 400 on any single invalid event.
9
+ *
10
+ * No valid events are lost — `flush()` answers a non-retryable 4xx by isolating
11
+ * the batch one event at a time. What it costs is that isolation pass: one
12
+ * failed batch POST followed by up to `batchSize` individual POSTs, on a mobile
13
+ * radio, to deliver events that would have gone in a single request. The
14
+ * offending event is discarded either way.
15
+ *
16
+ * Rejecting here loses exactly the same one event, spends no requests, and
17
+ * tells the app through `onError` instead of failing silently.
18
+ *
19
+ * The values mirror `packages/shared/src/events.ts`, which is the authority.
20
+ */
21
+
22
+ /** `clientEventSchema.name` — z.string().min(1).max(128). */
23
+ internal const val MAX_NAME_LENGTH: Int = 128
24
+
25
+ /** `propertiesSchema` — at most this many keys. */
26
+ internal const val MAX_PROPERTIES_KEYS: Int = 50
27
+
28
+ /** `propertiesSchema` — the serialised length must not exceed this. */
29
+ internal const val MAX_PROPERTIES_BYTES: Int = 8192
30
+
31
+ /** Stable reason string, or null when the input is acceptable. */
32
+ internal fun checkTrackInput(name: String, properties: Map<String, Any?>? = null): String? {
33
+ if (name.isEmpty()) return "name_empty"
34
+ if (name.length > MAX_NAME_LENGTH) return "name_too_long"
35
+ if (properties == null) return null
36
+ if (properties.size > MAX_PROPERTIES_KEYS) return "properties_too_many_keys"
37
+ val serialised =
38
+ try {
39
+ writeCanonicalJson(properties)
40
+ } catch (_: IllegalArgumentException) {
41
+ // A value the writer refuses would otherwise throw inside the transport,
42
+ // after it had already displaced a queue slot.
43
+ return "properties_unserialisable"
44
+ }
45
+ if (serialised.length > MAX_PROPERTIES_BYTES) return "properties_too_large"
46
+ return null
47
+ }
@@ -0,0 +1,80 @@
1
+ package io.advenue.core
2
+
3
+ /**
4
+ * A Meta install referrer, parsed.
5
+ *
6
+ * [encryptedData] and [nonce] are the claim the server decrypts with the app's
7
+ * Meta decryption key; the key never reaches the device, so the SDK carries the
8
+ * ciphertext and nothing more.
9
+ */
10
+ internal data class MetaReferrer(
11
+ val source: String?,
12
+ val campaign: String?,
13
+ val isClickThrough: Boolean,
14
+ /** Meta's own click/view time, in seconds. */
15
+ val timestamp: Long,
16
+ val encryptedData: String?,
17
+ val nonce: String?,
18
+ /** The provider's string, verbatim, for diagnostics. */
19
+ val raw: String,
20
+ )
21
+
22
+ /**
23
+ * Parses what the Meta install-referrer provider returned.
24
+ *
25
+ * Meta ships two shapes through the same column and the difference is not
26
+ * documented as a version: a JSON object when the referrer carries an encrypted
27
+ * claim, and a plain utm query string otherwise. Reading only the first would
28
+ * drop every organic-looking Meta install; reading only the second would drop
29
+ * every claim the server can actually decrypt.
30
+ *
31
+ * The JSON branch is tried first and, when the blob parses as an object, it
32
+ * OWNS the result — falling through to the query-string parser on a JSON object
33
+ * that merely lacks a claim would read the whole blob as one malformed key and
34
+ * report a campaign that does not exist.
35
+ *
36
+ * `network` is deliberately not derived here. The server assigns it from the
37
+ * decrypted claim (`meta_referrer` for a click, `meta_referrer_view` for a
38
+ * view), and a second answer set on the device would compete with the one that
39
+ * actually knows.
40
+ */
41
+ internal fun parseMetaInstallReferrer(
42
+ raw: String,
43
+ isClickThrough: Boolean,
44
+ actualTimestamp: Long,
45
+ ): MetaReferrer? {
46
+ if (raw.isEmpty()) return null
47
+
48
+ val json = parseJson(raw) as? Map<*, *>
49
+ if (json != null) {
50
+ val claim = ((json["utm_content"] as? Map<*, *>)?.get("source")) as? Map<*, *>
51
+ // All or nothing. The server needs BOTH halves to decrypt, so a lone nonce
52
+ // is not half a claim — it is noise on the event that nothing will read.
53
+ val data = claim?.get("data") as? String
54
+ val nonce = claim?.get("nonce") as? String
55
+ val paired = if (!data.isNullOrEmpty() && !nonce.isNullOrEmpty()) data to nonce else null
56
+ return MetaReferrer(
57
+ source = json["utm_source"] as? String,
58
+ campaign = json["utm_campaign"] as? String,
59
+ isClickThrough = isClickThrough,
60
+ timestamp = actualTimestamp,
61
+ // Strings only. A number where `data` belongs is a shape the server
62
+ // cannot decrypt, and sending it credits the install to nobody — worse
63
+ // than reporting no claim at all.
64
+ encryptedData = paired?.first,
65
+ nonce = paired?.second,
66
+ raw = raw,
67
+ )
68
+ }
69
+
70
+ val parsed = parseInstallReferrer(raw)
71
+ return MetaReferrer(
72
+ source = parsed.source,
73
+ campaign = parsed.campaign,
74
+ isClickThrough = isClickThrough,
75
+ timestamp = actualTimestamp,
76
+ encryptedData = null,
77
+ nonce = null,
78
+ raw = raw,
79
+ )
80
+ }
@@ -0,0 +1,189 @@
1
+ package io.advenue.core
2
+
3
+ internal const val SESSION_STATE_KEY: String = "advenue.session"
4
+
5
+ /** 30 minutes. */
6
+ internal const val DEFAULT_SESSION_WINDOW_MS: Long = 1_800_000L
7
+
8
+ /** A session lifecycle event, wrapped by the engine as a `type: "session"` event. */
9
+ internal data class SessionEvent(val name: String, val properties: Map<String, Any?>)
10
+
11
+ private data class SessionState(
12
+ val sessionId: String,
13
+ val sessionNumber: Long,
14
+ val lastBackgroundAt: Long?,
15
+ val subSessionCount: Long,
16
+ val activeStart: Long?,
17
+ val firstForegroundAt: Long,
18
+ val timeSpentMs: Long,
19
+ ) {
20
+ /**
21
+ * The persisted blob. Field names are frozen: a rename would make RN-written
22
+ * state unloadable and silently restart every device's session numbering.
23
+ */
24
+ fun toMap(): Map<String, Any?> =
25
+ mapOf(
26
+ "sessionId" to sessionId,
27
+ "sessionNumber" to sessionNumber,
28
+ "lastBackgroundAt" to lastBackgroundAt,
29
+ "subSessionCount" to subSessionCount,
30
+ "activeStart" to activeStart,
31
+ "firstForegroundAt" to firstForegroundAt,
32
+ "timeSpentMs" to timeSpentMs,
33
+ )
34
+ }
35
+
36
+ /**
37
+ * Platform-agnostic session state machine. Pure: it persists state and returns
38
+ * the events to emit; enqueueing is the caller's job. Driven by foreground and
39
+ * background lifecycle signals.
40
+ */
41
+ internal class SessionTracker(
42
+ private val store: KeyValueStore,
43
+ private val clock: Clock,
44
+ private val windowMs: Long = DEFAULT_SESSION_WINDOW_MS,
45
+ private val uuid: UuidSource,
46
+ ) {
47
+ private var lastState: SessionState? = null
48
+
49
+ /** Drops the cached state (erasure) so a wiped store cannot be resurrected. */
50
+ public fun reset() {
51
+ lastState = null
52
+ }
53
+
54
+ /**
55
+ * Foreground transition, including cold start. Returns:
56
+ * - empty — a sub-session inside the window;
57
+ * - `[session_start]` — a new session;
58
+ * - `[session_end, session_start]` — kill recovery: the prior session was
59
+ * still open when the OS killed the app, so its end is synthesised first.
60
+ */
61
+ public fun handleForeground(): List<SessionEvent> {
62
+ val state = load()
63
+ val now = clock.nowMs()
64
+ val gap = if (state?.lastBackgroundAt != null) now - state.lastBackgroundAt else null
65
+
66
+ if (state == null || gap == null || gap >= windowMs) {
67
+ val sessionNumber = (state?.sessionNumber ?: 0L) + 1L
68
+ val sessionId = uuid.next()
69
+ save(
70
+ SessionState(
71
+ sessionId = sessionId,
72
+ sessionNumber = sessionNumber,
73
+ lastBackgroundAt = null,
74
+ subSessionCount = 1L,
75
+ activeStart = now,
76
+ firstForegroundAt = now,
77
+ timeSpentMs = 0L,
78
+ ),
79
+ )
80
+
81
+ val sessionStart =
82
+ SessionEvent(
83
+ "session_start",
84
+ mapOf(
85
+ "sessionId" to sessionId,
86
+ "sessionNumber" to sessionNumber,
87
+ "subSession" to 1L,
88
+ "isFirstSession" to (sessionNumber == 1L),
89
+ // 0 rather than infinity, which would not survive JSON.
90
+ "timeSinceLastSessionMs" to (gap ?: 0L),
91
+ ),
92
+ )
93
+
94
+ if (state?.activeStart != null) {
95
+ val killTime = state.lastBackgroundAt ?: now
96
+ val activeMs = Math.max(0L, killTime - state.activeStart)
97
+ val timeSpentMs = state.timeSpentMs + activeMs
98
+ val sessionEnd =
99
+ SessionEvent(
100
+ "session_end",
101
+ mapOf(
102
+ "sessionId" to state.sessionId,
103
+ "sessionNumber" to state.sessionNumber,
104
+ "subSession" to state.subSessionCount,
105
+ "activeMs" to activeMs,
106
+ "timeSpentMs" to timeSpentMs,
107
+ "sessionLengthMs" to Math.max(0L, killTime - state.firstForegroundAt),
108
+ "synthetic" to true,
109
+ ),
110
+ )
111
+ return listOf(sessionEnd, sessionStart)
112
+ }
113
+
114
+ return listOf(sessionStart)
115
+ }
116
+
117
+ save(state.copy(subSessionCount = state.subSessionCount + 1L, activeStart = now))
118
+ return emptyList()
119
+ }
120
+
121
+ /** Background transition. Returns a session_end with the active duration, or null. */
122
+ public fun handleBackground(): SessionEvent? {
123
+ val state = load() ?: return null
124
+ val activeStart = state.activeStart ?: return null
125
+ val now = clock.nowMs()
126
+ val activeMs = Math.max(0L, now - activeStart)
127
+ val timeSpentMs = state.timeSpentMs + activeMs
128
+ save(state.copy(lastBackgroundAt = now, activeStart = null, timeSpentMs = timeSpentMs))
129
+ return SessionEvent(
130
+ "session_end",
131
+ mapOf(
132
+ "sessionId" to state.sessionId,
133
+ "sessionNumber" to state.sessionNumber,
134
+ "subSession" to state.subSessionCount,
135
+ // This sub-session's foreground stretch.
136
+ "activeMs" to activeMs,
137
+ // Cumulative active time across the session's sub-sessions.
138
+ "timeSpentMs" to timeSpentMs,
139
+ // Wall-clock span.
140
+ "sessionLengthMs" to Math.max(0L, now - state.firstForegroundAt),
141
+ ),
142
+ )
143
+ }
144
+
145
+ private fun load(): SessionState? {
146
+ lastState?.let { return it }
147
+ val raw = store.getString(SESSION_STATE_KEY) ?: return null
148
+ val map = parseJson(raw) as? Map<*, *> ?: return null
149
+
150
+ val sessionId = map["sessionId"] as? String ?: return null
151
+ val sessionNumber = (map["sessionNumber"] as? Number)?.toLong() ?: return null
152
+ val subSessionCount = (map["subSessionCount"] as? Number)?.toLong() ?: return null
153
+ val lastBackgroundAt = (map["lastBackgroundAt"] as? Number)?.toLong()
154
+ val activeStart = (map["activeStart"] as? Number)?.toLong()
155
+
156
+ // Back-compat for state persisted before these two fields existed. Without
157
+ // it, an app updating from an older SDK would fail to load its own session
158
+ // and restart its numbering.
159
+ val timeSpentMs = (map["timeSpentMs"] as? Number)?.toLong() ?: 0L
160
+ val firstForegroundAt =
161
+ (map["firstForegroundAt"] as? Number)?.toLong()
162
+ ?: activeStart
163
+ ?: lastBackgroundAt
164
+ ?: clock.nowMs()
165
+
166
+ val state =
167
+ SessionState(
168
+ sessionId = sessionId,
169
+ sessionNumber = sessionNumber,
170
+ lastBackgroundAt = lastBackgroundAt,
171
+ subSessionCount = subSessionCount,
172
+ activeStart = activeStart,
173
+ firstForegroundAt = firstForegroundAt,
174
+ timeSpentMs = timeSpentMs,
175
+ )
176
+ lastState = state
177
+ return state
178
+ }
179
+
180
+ private fun save(state: SessionState) {
181
+ lastState = state
182
+ try {
183
+ store.setString(SESSION_STATE_KEY, writeCanonicalJson(state.toMap()))
184
+ } catch (_: Exception) {
185
+ // The in-memory state keeps this process consistent even if the write
186
+ // failed; losing session continuity is better than crashing the host app.
187
+ }
188
+ }
189
+ }
@@ -0,0 +1,58 @@
1
+ package io.advenue.core
2
+
3
+ import java.util.Timer
4
+ import java.util.TimerTask
5
+ import java.util.UUID
6
+ import java.util.concurrent.ConcurrentHashMap
7
+ import java.util.concurrent.atomic.AtomicLong
8
+
9
+ /** Wall clock. */
10
+ internal class SystemClock : Clock {
11
+ override fun nowMs(): Long = System.currentTimeMillis()
12
+ }
13
+
14
+ /** Random v4 identifiers, lowercase, matching what TypeScript's generator emits. */
15
+ internal class SystemUuids : UuidSource {
16
+ override fun next(): String = UUID.randomUUID().toString()
17
+ }
18
+
19
+ /**
20
+ * Deferred execution for the queue's persist debounce.
21
+ *
22
+ * **Isolation contract.** The scheduled work touches the event list, which is
23
+ * confined to the engine's thread. Running it on the timer thread would race
24
+ * `enqueue` — an iteration over an `ArrayList` while another thread appends —
25
+ * so the timer only *wakes*, and [dispatcher] hops the work back onto the
26
+ * engine's thread. The Swift port put the same requirement on its `Scheduler`
27
+ * protocol for exactly this reason.
28
+ *
29
+ * [dispatcher] is assigned after the pipe exists. Until then it runs inline,
30
+ * which is safe because nothing can be enqueued before the pipe exists, so no
31
+ * persist can have been scheduled.
32
+ */
33
+ internal class TimerScheduler : Scheduler {
34
+ private val timer = Timer("advenue-scheduler", true)
35
+ private val tasks = ConcurrentHashMap<CancelToken, TimerTask>()
36
+ private val next = AtomicLong(1)
37
+
38
+ @Volatile public var dispatcher: ((() -> Unit) -> Unit)? = null
39
+
40
+ override fun schedule(afterMs: Int, work: () -> Unit): CancelToken {
41
+ val token = next.getAndIncrement()
42
+ val task =
43
+ object : TimerTask() {
44
+ override fun run() {
45
+ tasks.remove(token)
46
+ val hop = dispatcher
47
+ if (hop != null) hop(work) else work()
48
+ }
49
+ }
50
+ tasks[token] = task
51
+ timer.schedule(task, afterMs.toLong())
52
+ return token
53
+ }
54
+
55
+ override fun cancel(token: CancelToken) {
56
+ tasks.remove(token)?.cancel()
57
+ }
58
+ }
@@ -0,0 +1,48 @@
1
+ package io.advenue.core
2
+
3
+ /**
4
+ * Maps raw IAB TCF v2 CMP data to the DMA [Consent] shape, per Google's EU User
5
+ * Consent Policy reading of the TCF purposes:
6
+ *
7
+ * - `ad_storage` ← Purpose 1
8
+ * - `ad_user_data` ← Purposes 1 **and** 7
9
+ * - `ad_personalization` ← Purposes 3 **and** 4
10
+ *
11
+ * Returns null when `gdprApplies` is neither 0 nor 1. Absent consent must stay
12
+ * absent: a guessed value is forwarded to ad networks as a real signal, and a
13
+ * signal invented on a user's behalf is the one thing consent plumbing must
14
+ * never do.
15
+ */
16
+ internal fun tcfToConsent(gdprApplies: Int?, purposeConsents: String?): Consent? {
17
+ if (gdprApplies != 0 && gdprApplies != 1) return null
18
+ if (gdprApplies == 0) return Consent(isUserSubjectToGDPR = false)
19
+
20
+ val consents = purposeConsents ?: ""
21
+ fun purpose(n: Int): Boolean = consents.getOrNull(n - 1) == '1'
22
+
23
+ return Consent(
24
+ isUserSubjectToGDPR = true,
25
+ hasConsentForAdStorage = purpose(1),
26
+ hasConsentForDataUsage = purpose(1) && purpose(7),
27
+ hasConsentForAdsPersonalization = purpose(3) && purpose(4),
28
+ )
29
+ }
30
+
31
+ /**
32
+ * Meta AEM `campaign_ids`, extracted from an `al_applink_data` payload.
33
+ *
34
+ * The input is the ALREADY percent-decoded query value: decoding again would
35
+ * corrupt a blob containing '%'. Meta does not publicly document the schema, so
36
+ * both shapes seen in the wild are accepted, top level first.
37
+ *
38
+ * The blob is opaque, Meta-encrypted, non-user metadata and is returned
39
+ * verbatim — never trimmed or re-encoded. The 2048 cap is URL hygiene; the
40
+ * server re-validates, because an SDK-side cap is advisory at a trust boundary.
41
+ */
42
+ internal fun extractAemCampaignIds(alApplinkData: String): String? {
43
+ val parsed = parseJson(alApplinkData) as? Map<*, *> ?: return null
44
+ val top = parsed["campaign_ids"] as? String
45
+ val nested = (parsed["extras"] as? Map<*, *>)?.get("campaign_ids") as? String
46
+ val value = top?.takeIf { it.isNotEmpty() } ?: nested?.takeIf { it.isNotEmpty() } ?: return null
47
+ return value.takeIf { it.length <= 2048 }
48
+ }