@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.
- package/README.md +8 -7
- package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +118 -347
- package/android/src/main/kotlin/io/advenue/Advenue.kt +549 -0
- package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +114 -0
- package/android/src/main/kotlin/io/advenue/core/Backoff.kt +28 -0
- package/android/src/main/kotlin/io/advenue/core/ClientEvent.kt +136 -0
- package/android/src/main/kotlin/io/advenue/core/CommandPipe.kt +186 -0
- package/android/src/main/kotlin/io/advenue/core/Consent.kt +52 -0
- package/android/src/main/kotlin/io/advenue/core/Contracts.kt +80 -0
- package/android/src/main/kotlin/io/advenue/core/Conversion.kt +69 -0
- package/android/src/main/kotlin/io/advenue/core/Engine.kt +414 -0
- package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +89 -0
- package/android/src/main/kotlin/io/advenue/core/HmacSigner.kt +31 -0
- package/android/src/main/kotlin/io/advenue/core/InstallReferrer.kt +146 -0
- package/android/src/main/kotlin/io/advenue/core/Json.kt +272 -0
- package/android/src/main/kotlin/io/advenue/core/Limits.kt +47 -0
- package/android/src/main/kotlin/io/advenue/core/MetaReferrer.kt +80 -0
- package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +189 -0
- package/android/src/main/kotlin/io/advenue/core/SystemServices.kt +58 -0
- package/android/src/main/kotlin/io/advenue/core/Tcf.kt +48 -0
- package/android/src/main/kotlin/io/advenue/core/Time.kt +56 -0
- package/android/src/main/kotlin/io/advenue/platform/Collectors.kt +149 -0
- package/android/src/main/kotlin/io/advenue/platform/CompositeStore.kt +33 -0
- package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +68 -0
- package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +90 -0
- package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +101 -0
- package/android/src/main/kotlin/io/advenue/platform/Identity.kt +79 -0
- package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +197 -0
- package/android/src/main/kotlin/io/advenue/platform/InstallScopedStore.kt +91 -0
- package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +72 -0
- package/android/src/main/kotlin/io/advenue/platform/PreferencesStore.kt +32 -0
- package/android/src/main/kotlin/io/advenue/plugin/Contracts.kt +67 -0
- package/android/src/main/kotlin/io/advenue/plugin/FirebaseAppInstanceIdSource.kt +73 -0
- package/android/src/main/kotlin/io/advenue/plugin/PlayAdvertisingIdSource.kt +47 -0
- package/android/src/main/kotlin/io/advenue/plugin/PlayInstallReferrerSource.kt +85 -0
- package/android/src/main/kotlin/io/advenue/plugin/PlayIntegritySource.kt +86 -0
- package/android/src/main/kotlin/io/advenue/plugin/PluginRegistry.kt +61 -0
- package/dist/index.cjs +183 -655
- package/dist/index.d.cts +199 -331
- package/dist/index.d.ts +199 -331
- package/dist/index.js +182 -661
- package/ios/AdvenueIosModule.swift +144 -432
- package/ios/vendor/Advenue/Advenue.swift +596 -0
- package/ios/vendor/Advenue/AdvenueConfig.swift +77 -0
- package/ios/vendor/AdvenueCore/AdvenueValue.swift +103 -0
- package/ios/vendor/AdvenueCore/Attestation.swift +52 -0
- package/ios/vendor/AdvenueCore/Backoff.swift +28 -0
- package/ios/vendor/AdvenueCore/ClientEvent.swift +154 -0
- package/ios/vendor/AdvenueCore/Consent.swift +40 -0
- package/ios/vendor/AdvenueCore/Contracts.swift +59 -0
- package/ios/vendor/AdvenueCore/Conversion.swift +74 -0
- package/ios/vendor/AdvenueCore/ConversionValue.swift +217 -0
- package/ios/vendor/AdvenueCore/Engine.swift +587 -0
- package/ios/vendor/AdvenueCore/EventQueue.swift +89 -0
- package/ios/vendor/AdvenueCore/Limits.swift +41 -0
- package/ios/vendor/AdvenueCore/PIIScrub.swift +102 -0
- package/ios/vendor/AdvenueCore/SessionTracker.swift +139 -0
- package/ios/vendor/AdvenueCore/SkanConfig.swift +76 -0
- package/ios/vendor/AdvenueCore/SkanReporter.swift +25 -0
- package/ios/vendor/AdvenueCore/SkanState.swift +258 -0
- package/ios/vendor/AdvenueCore/Tcf.swift +48 -0
- package/ios/vendor/AdvenueCore/Transport.swift +14 -0
- package/ios/vendor/AdvenueFirebase/FirebaseAppInstanceId.swift +33 -0
- package/ios/vendor/AdvenuePlatform/AdvertisingIdentity.swift +87 -0
- package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +42 -0
- package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +63 -0
- package/ios/vendor/AdvenuePlatform/CryptoKitSigner.swift +19 -0
- package/ios/vendor/AdvenuePlatform/DeviceCheckAttestation.swift +91 -0
- package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +65 -0
- package/ios/vendor/AdvenuePlatform/ForegroundTracker.swift +55 -0
- package/ios/vendor/AdvenuePlatform/HttpTransport.swift +96 -0
- package/ios/vendor/AdvenuePlatform/Identity.swift +52 -0
- package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +143 -0
- package/ios/vendor/AdvenuePlatform/KeychainStore.swift +95 -0
- package/ios/vendor/AdvenuePlatform/SearchAdsToken.swift +76 -0
- package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +70 -0
- package/ios/vendor/AdvenuePlatform/StoreKitSkanReporter.swift +140 -0
- package/ios/vendor/AdvenuePlatform/SystemServices.swift +55 -0
- package/ios/vendor/AdvenuePlatform/TcfReader.swift +18 -0
- package/ios/vendor/AdvenuePlatform/UserDefaultsStore.swift +26 -0
- package/package.json +9 -11
- package/scripts/check-dist.mjs +17 -0
- package/scripts/check-vendored-swift.mjs +148 -0
- package/scripts/vendor-natives.mjs +149 -0
- package/scripts/vendor-natives.test.mjs +112 -0
- package/src/deep-links.ts +19 -1
- package/src/index.ts +267 -803
- package/src/native-types.ts +74 -171
- package/src/native.ts +0 -23
- package/src/types.ts +81 -0
- package/src/aem.ts +0 -33
- package/src/mmkv-storage.ts +0 -21
- package/src/native-storage.ts +0 -63
- 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
|
+
}
|