@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.
- package/README.md +8 -7
- package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +117 -368
- package/android/src/main/kotlin/io/advenue/Advenue.kt +574 -0
- package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +141 -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 +193 -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 +436 -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 +50 -0
- package/android/src/main/kotlin/io/advenue/core/MetaReferrer.kt +80 -0
- package/android/src/main/kotlin/io/advenue/core/PiiScrub.kt +77 -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 +163 -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 +96 -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 +188 -675
- package/dist/index.d.cts +203 -336
- package/dist/index.d.ts +203 -336
- package/dist/index.js +187 -681
- package/ios/AdvenueIosModule.swift +143 -446
- package/ios/vendor/Advenue/Advenue.swift +632 -0
- package/ios/vendor/Advenue/AdvenueConfig.swift +89 -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 +607 -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/CacheFileStore.swift +90 -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 +77 -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 +155 -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 +13 -15
- 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 +273 -830
- package/src/native-types.ts +73 -181
- package/src/native.ts +0 -23
- package/src/types.ts +86 -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,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
|
+
}
|