@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,146 @@
1
+ package io.advenue.core
2
+
3
+ import java.net.URLDecoder
4
+
5
+ /**
6
+ * A parsed Play Install Referrer. Ported field for field from
7
+ * `packages/install-referrer/src/index.ts`, which is the authority — the server
8
+ * attributes on these names.
9
+ */
10
+ internal data class ParsedReferrer(
11
+ val network: String?,
12
+ val source: String?,
13
+ val medium: String?,
14
+ val campaign: String?,
15
+ val content: String?,
16
+ val term: String?,
17
+ /** Google click id (paid Google/UAC). */
18
+ val gclid: String?,
19
+ /** Meta/Facebook click id. */
20
+ val fbclid: String?,
21
+ /** Full decoded key/value map, for anything non-standard. */
22
+ val params: Map<String, String>,
23
+ val raw: String,
24
+ )
25
+
26
+ private val SOURCE_NETWORK =
27
+ mapOf(
28
+ "google" to "google",
29
+ "google-play" to "google",
30
+ "googleads" to "google",
31
+ "google-ads" to "google",
32
+ "adwords" to "google",
33
+ "uac" to "google",
34
+ "admob" to "admob",
35
+ "facebook" to "meta",
36
+ "facebook-ads" to "meta",
37
+ "fb" to "meta",
38
+ "instagram" to "meta",
39
+ "ig" to "meta",
40
+ "meta" to "meta",
41
+ "meta-ads" to "meta",
42
+ "tiktok" to "tiktok",
43
+ "tiktok-ads" to "tiktok",
44
+ "tiktokads" to "tiktok",
45
+ "apple-search-ads" to "apple",
46
+ "apple_search_ads" to "apple",
47
+ "apple-ads" to "apple",
48
+ "asa" to "apple",
49
+ "snapchat" to "snapchat",
50
+ "x" to "x",
51
+ "twitter" to "x",
52
+ "youtube" to "youtube",
53
+ )
54
+
55
+ /**
56
+ * Parses a Play Install Referrer string (or any utm-tagged referrer) into
57
+ * normalised attribution params.
58
+ */
59
+ internal fun parseInstallReferrer(raw: String): ParsedReferrer {
60
+ // A fully percent-encoded referrer has no literal '='; decode it once first.
61
+ // An already-decoded referrer keeps its '=' and is decoded per key/value
62
+ // below — decoding the whole string would turn an encoded '&' or '=' inside a
63
+ // value into a false separator.
64
+ val decoded = if (raw.contains('=')) raw else safeDecode(raw)
65
+
66
+ val params = LinkedHashMap<String, String>()
67
+ for (pair in decoded.split('&')) {
68
+ if (pair.isEmpty()) continue
69
+ val eq = pair.indexOf('=')
70
+ val key = if (eq >= 0) pair.substring(0, eq) else pair
71
+ val value = if (eq >= 0) pair.substring(eq + 1) else ""
72
+ if (key.isNotEmpty()) params[safeDecode(key)] = safeDecode(value.replace("+", " "))
73
+ }
74
+
75
+ return ParsedReferrer(
76
+ network = inferNetwork(params),
77
+ source = params["utm_source"],
78
+ medium = params["utm_medium"],
79
+ campaign = params["utm_campaign"] ?: params["campaign"],
80
+ content = params["utm_content"],
81
+ term = params["utm_term"],
82
+ gclid = params["gclid"],
83
+ fbclid = params["fbclid"],
84
+ params = params,
85
+ raw = raw,
86
+ )
87
+ }
88
+
89
+ private fun inferNetwork(params: Map<String, String>): String? {
90
+ if (params["gclid"] != null) return "google"
91
+ val source = params["utm_source"]?.trim()?.lowercase()
92
+ if (source.isNullOrEmpty()) return null
93
+
94
+ // The Play Store's DEFAULT referrer for an organic install is
95
+ // "utm_source=google-play&utm_medium=organic" — the store itself, not Google
96
+ // Ads. Without a paid click id (gclid, above) or a paid medium these MUST
97
+ // stay organic, or every organic Android install is credited to paid Google.
98
+ // That is a financial misattribution, not a reporting nuance.
99
+ val medium = params["utm_medium"]?.trim()?.lowercase()
100
+ if (medium == "organic") return null
101
+ if ((source == "google-play" || source == "com.android.vending") && medium.isNullOrEmpty()) {
102
+ return null
103
+ }
104
+
105
+ // Exact token match against known networks; anything else passes through as
106
+ // its own lower-cased key. An unknown utm_source is a real source, it just is
107
+ // not one we can canonicalise.
108
+ return SOURCE_NETWORK[source] ?: source
109
+ }
110
+
111
+ /**
112
+ * The referrer fields that ride the install event.
113
+ *
114
+ * Device-clock values always travel under the client-trust keys. The
115
+ * server-trusted keys are populated **only** from Play's genuine server-clock
116
+ * getters and only when non-zero, because Play returns 0 for an unverified
117
+ * install. Aliasing a device clock into a server key would hand the fraud
118
+ * scorer a trust signal the device could forge.
119
+ */
120
+ internal fun referrerProperties(
121
+ referrer: String,
122
+ clickTimestamp: Long,
123
+ installTimestamp: Long,
124
+ clickServerTimestamp: Long,
125
+ installServerTimestamp: Long,
126
+ googlePlayInstant: Boolean,
127
+ ): Map<String, Any?> {
128
+ val props =
129
+ linkedMapOf<String, Any?>(
130
+ "installReferrer" to referrer,
131
+ "referrerClickTimestamp" to clickTimestamp,
132
+ "installBeginTimestamp" to installTimestamp,
133
+ "googlePlayInstant" to googlePlayInstant,
134
+ )
135
+ if (clickServerTimestamp != 0L) props["referrerClickServerTimestamp"] = clickServerTimestamp
136
+ if (installServerTimestamp != 0L) props["installBeginServerTimestamp"] = installServerTimestamp
137
+ return props
138
+ }
139
+
140
+ private fun safeDecode(value: String): String =
141
+ try {
142
+ URLDecoder.decode(value, "UTF-8")
143
+ } catch (_: Exception) {
144
+ // A malformed escape must cost the decoding, not the install.
145
+ value
146
+ }
@@ -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,50 @@
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
+ /** `customerUserId` — z.string().max(128). Üstü batch'i 400'e düşürür. */
26
+ internal const val MAX_USER_ID_LENGTH: Int = 128
27
+
28
+ /** `propertiesSchema` — at most this many keys. */
29
+ internal const val MAX_PROPERTIES_KEYS: Int = 50
30
+
31
+ /** `propertiesSchema` — the serialised length must not exceed this. */
32
+ internal const val MAX_PROPERTIES_BYTES: Int = 8192
33
+
34
+ /** Stable reason string, or null when the input is acceptable. */
35
+ internal fun checkTrackInput(name: String, properties: Map<String, Any?>? = null): String? {
36
+ if (name.isEmpty()) return "name_empty"
37
+ if (name.length > MAX_NAME_LENGTH) return "name_too_long"
38
+ if (properties == null) return null
39
+ if (properties.size > MAX_PROPERTIES_KEYS) return "properties_too_many_keys"
40
+ val serialised =
41
+ try {
42
+ writeCanonicalJson(properties)
43
+ } catch (_: IllegalArgumentException) {
44
+ // A value the writer refuses would otherwise throw inside the transport,
45
+ // after it had already displaced a queue slot.
46
+ return "properties_unserialisable"
47
+ }
48
+ if (serialised.length > MAX_PROPERTIES_BYTES) return "properties_too_large"
49
+ return null
50
+ }
@@ -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,77 @@
1
+ package io.advenue.core
2
+
3
+ /**
4
+ * B1: track properties PII scrubbing (varsayılan-açık) — Swift `PIIScrub`
5
+ * ile aynı sözleşme. Anahtar-adı tabanlı + değer-örüntü tabanlı eşleşen
6
+ * değerler gönderilmeden önce kaldırılır; ham değer ağa çıkmaz.
7
+ */
8
+ internal object PiiScrub {
9
+ private val blockedKeys: Set<String> = setOf(
10
+ "email", "emailaddress", "emailadres", "eposta", "mail",
11
+ "phone", "phonenumber", "mobile", "mobilenumber", "mobil",
12
+ "telefon", "telefonno", "tel", "telno", "gsm",
13
+ "firstname", "lastname", "ad", "soyad", "adsoyad", "adisoyadi",
14
+ "fullname", "namesurname", "isim", "isimsoyisim",
15
+ )
16
+
17
+ private val blockedSubstrings: List<String> = listOf(
18
+ "email", "phone", "telefon", "soyad", "adsoyad", "firstname", "lastname",
19
+ "fullname",
20
+ )
21
+
22
+ private val emailRegex =
23
+ Regex("[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}", RegexOption.IGNORE_CASE)
24
+
25
+ private val phoneChars: Set<Char> = "+0123456789 \t.-()".toSet()
26
+
27
+ internal fun normalizeKey(key: String): String =
28
+ key.lowercase().filter { it.isLetterOrDigit() }
29
+
30
+ internal fun isSensitiveKey(key: String): Boolean {
31
+ val normalized = normalizeKey(key)
32
+ if (blockedKeys.contains(normalized)) return true
33
+ return blockedSubstrings.any { normalized.contains(it) }
34
+ }
35
+
36
+ /**
37
+ * Telefon sezgiseli (Swift ile aynı): çapasız alt-dize araması YOK.
38
+ * Değerin TÜMÜ telefon karakterlerinden oluşmalı ve rakam sayısı
39
+ * E.164 aralığında (10–15) olmalı.
40
+ */
41
+ internal fun looksLikePhone(value: String): Boolean {
42
+ val trimmed = value.trim()
43
+ if (trimmed.isEmpty()) return false
44
+ if (!trimmed.all { phoneChars.contains(it) }) return false
45
+ val first = trimmed.first()
46
+ val last = trimmed.last()
47
+ if (!(first == '+' || first.isDigit()) || !last.isDigit()) return false
48
+ return trimmed.count { it.isDigit() } in 10..15
49
+ }
50
+
51
+ internal fun isSensitiveValue(value: String): Boolean =
52
+ emailRegex.containsMatchIn(value) || looksLikePhone(value)
53
+
54
+ private fun scrubValue(value: Any?): Any? =
55
+ when (value) {
56
+ is String -> if (isSensitiveValue(value)) null else value
57
+ is Map<*, *> -> {
58
+ @Suppress("UNCHECKED_CAST")
59
+ val cleaned = scrub(value as Map<String, Any?>) ?: return null
60
+ cleaned
61
+ }
62
+ is List<*> -> value.mapNotNull { scrubValue(it) }
63
+ else -> value
64
+ }
65
+
66
+ /** PII taşıyan girdileri kaldırır. Girdi null ise null döner. */
67
+ internal fun scrub(properties: Map<String, Any?>?): Map<String, Any?>? {
68
+ if (properties == null) return null
69
+ val kept = LinkedHashMap<String, Any?>(properties.size)
70
+ for ((key, value) in properties) {
71
+ if (isSensitiveKey(key)) continue
72
+ val cleaned = scrubValue(value) ?: continue
73
+ kept[key] = cleaned
74
+ }
75
+ return kept
76
+ }
77
+ }