@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,414 @@
|
|
|
1
|
+
package io.advenue.core
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The three install-scoped keys. All of them die with the app: Android has no
|
|
5
|
+
* reinstall-durable store, so a reinstall is legitimately a new install and the
|
|
6
|
+
* Keychain asymmetry the iOS SDK relies on does not exist here.
|
|
7
|
+
*/
|
|
8
|
+
internal const val DEVICE_ID_KEY: String = "advenue.device_id"
|
|
9
|
+
|
|
10
|
+
internal const val INSTALLATION_ID_KEY: String = "advenue.installation_id"
|
|
11
|
+
|
|
12
|
+
internal const val INSTALL_SENT_KEY: String = "advenue.install_sent"
|
|
13
|
+
/**
|
|
14
|
+
* The server rejects a batch of more than this: `eventBatchSchema` in
|
|
15
|
+
* `packages/shared/src/events.ts` is
|
|
16
|
+
* `z.array(clientEventSchema).min(1).max(100)`.
|
|
17
|
+
*
|
|
18
|
+
* It lives in the core rather than beside the transport because it is a fact
|
|
19
|
+
* about the wire, not about any one way of reaching it — and because the engine
|
|
20
|
+
* has to clamp to it, which the transport cannot do from where it sits.
|
|
21
|
+
*/
|
|
22
|
+
internal const val MAX_BATCH_SIZE: Int = 100
|
|
23
|
+
|
|
24
|
+
internal const val CONSENT_KEY: String = "advenue.consent"
|
|
25
|
+
internal const val CONSENT_DATA_KEY: String = "advenue.consent_data"
|
|
26
|
+
internal const val USER_ID_KEY: String = "advenue.user_id"
|
|
27
|
+
|
|
28
|
+
internal data class EngineConfig(
|
|
29
|
+
val apiKey: String,
|
|
30
|
+
val platform: String = "android",
|
|
31
|
+
val deviceId: String,
|
|
32
|
+
val installationId: String? = null,
|
|
33
|
+
val appVersion: String? = null,
|
|
34
|
+
val osVersion: String? = null,
|
|
35
|
+
val sdkVersion: String? = null,
|
|
36
|
+
val requireConsent: Boolean = false,
|
|
37
|
+
val sessionWindowMs: Long = DEFAULT_SESSION_WINDOW_MS,
|
|
38
|
+
val maxQueueSize: Int = 10_000,
|
|
39
|
+
val batchSize: Int = 20,
|
|
40
|
+
val retryBaseMs: Double = 1_000.0,
|
|
41
|
+
val retryCapMs: Double = 60_000.0,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Owns every piece of mutable SDK state.
|
|
46
|
+
*
|
|
47
|
+
* **Confinement, not locking.** Commands arrive through [CommandPipe] and are
|
|
48
|
+
* handled one at a time on a single thread, which reproduces sdk-core's
|
|
49
|
+
* single-threaded semantics — the property that makes the conformance vectors
|
|
50
|
+
* meaningful in the first place. This class takes no locks deliberately: a lock
|
|
51
|
+
* here would hide a confinement violation rather than prevent one.
|
|
52
|
+
*/
|
|
53
|
+
internal class AdvenueEngine(
|
|
54
|
+
private val config: EngineConfig,
|
|
55
|
+
private val store: KeyValueStore,
|
|
56
|
+
private val clock: Clock,
|
|
57
|
+
scheduler: Scheduler,
|
|
58
|
+
private val uuid: UuidSource,
|
|
59
|
+
private val transport: EventTransport? = null,
|
|
60
|
+
private val onDrop: (List<ClientEvent>) -> Unit = {},
|
|
61
|
+
private val random: () -> Double = { Math.random() },
|
|
62
|
+
private val onError: (String, Throwable) -> Unit = { _, _ -> },
|
|
63
|
+
) {
|
|
64
|
+
private val queue = EventQueue(store, config.maxQueueSize, scheduler)
|
|
65
|
+
private val sessions =
|
|
66
|
+
SessionTracker(store, clock, config.sessionWindowMs, uuid)
|
|
67
|
+
|
|
68
|
+
private var consent: Boolean = store.getString(CONSENT_KEY) == "granted"
|
|
69
|
+
private var forgotten = false
|
|
70
|
+
private var customerUserId: String? = store.getString(USER_ID_KEY)
|
|
71
|
+
private var idfa: String? = null
|
|
72
|
+
private var gaid: String? = null
|
|
73
|
+
private var androidId: String? = null
|
|
74
|
+
private var appInstanceId: String? = null
|
|
75
|
+
private var consentData: Consent? = readConsentData()
|
|
76
|
+
private var pushToken: String? = null
|
|
77
|
+
private var pushProvider: String? = null
|
|
78
|
+
private var flushing = false
|
|
79
|
+
private var consecutiveFailures = 0
|
|
80
|
+
private var backoffUntilMs = 0L
|
|
81
|
+
|
|
82
|
+
/** Enqueues an event, or refuses it and says why. Mirrors sdk-core's `track()`. */
|
|
83
|
+
public fun track(
|
|
84
|
+
name: String,
|
|
85
|
+
properties: Map<String, Any?>? = null,
|
|
86
|
+
type: String = "custom",
|
|
87
|
+
): Boolean {
|
|
88
|
+
if (forgotten || (config.requireConsent && !consent)) return false
|
|
89
|
+
checkTrackInput(name, properties)?.let { rejection ->
|
|
90
|
+
onError("track.rejected:$rejection", IngestError(400))
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
queue.enqueue(newEvent(type = type, name = name, properties = properties))
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
public fun setConsent(granted: Boolean) {
|
|
98
|
+
consent = granted
|
|
99
|
+
store.setString(CONSENT_KEY, if (granted) "granted" else "denied")
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Records granular ad-platform consent. Last write wins, and it is persisted,
|
|
104
|
+
* so it survives restarts — a stated preference silently reverting on
|
|
105
|
+
* relaunch is the failure this guards.
|
|
106
|
+
*/
|
|
107
|
+
internal fun setConsentData(consent: Consent?) {
|
|
108
|
+
if (forgotten) return
|
|
109
|
+
consentData = consent
|
|
110
|
+
if (consent == null) store.remove(CONSENT_DATA_KEY)
|
|
111
|
+
else store.setString(CONSENT_DATA_KEY, writeCanonicalJson(consent.toMap()))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
internal fun getConsentData(): Consent? = consentData
|
|
115
|
+
|
|
116
|
+
internal fun getConsent(): Boolean = consent
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Registers the device's push token for uninstall measurement (#26).
|
|
120
|
+
*
|
|
121
|
+
* The host app owns push registration: the SDK never asks for the
|
|
122
|
+
* notification permission and never displays anything. Pass the token your
|
|
123
|
+
* push library already gives you, on every launch — the OS can rotate it at
|
|
124
|
+
* any time, and a stale token is the one thing that makes uninstall
|
|
125
|
+
* measurement report churn that did not happen.
|
|
126
|
+
*/
|
|
127
|
+
internal fun setPushToken(token: String?, provider: String? = null) {
|
|
128
|
+
if (forgotten) return
|
|
129
|
+
if (token == null) {
|
|
130
|
+
pushToken = null
|
|
131
|
+
pushProvider = null
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
val trimmed = token.trim()
|
|
135
|
+
if (!isValidPushToken(trimmed)) {
|
|
136
|
+
pushToken = null
|
|
137
|
+
pushProvider = null
|
|
138
|
+
onError("push.setPushToken", IngestError(400))
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
pushToken = trimmed
|
|
142
|
+
pushProvider = provider ?: if (config.platform == "android") "fcm" else "apns"
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private fun readConsentData(): Consent? =
|
|
146
|
+
(parseJson(store.getString(CONSENT_DATA_KEY) ?: "") as? Map<*, *>)?.let(Consent::fromMap)
|
|
147
|
+
|
|
148
|
+
public fun setUserId(id: String?) {
|
|
149
|
+
customerUserId = id
|
|
150
|
+
if (id != null) store.setString(USER_ID_KEY, id) else store.remove(USER_ID_KEY)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Identity attached to every subsequent event. Not persisted: it is
|
|
155
|
+
* re-resolved each launch, because the advertising id and its limit flag can
|
|
156
|
+
* both change between them.
|
|
157
|
+
*/
|
|
158
|
+
public fun setIdentity(
|
|
159
|
+
gaid: String?,
|
|
160
|
+
androidId: String?,
|
|
161
|
+
appInstanceId: String?,
|
|
162
|
+
idfa: String? = null,
|
|
163
|
+
) {
|
|
164
|
+
this.gaid = gaid
|
|
165
|
+
this.androidId = androidId
|
|
166
|
+
this.appInstanceId = appInstanceId
|
|
167
|
+
this.idfa = idfa
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The Firebase App Instance ID alone. Separate from [setIdentity] because it
|
|
172
|
+
* arrives on its own schedule — an app can set it at any point, and folding
|
|
173
|
+
* it into the multi-field setter would clear the advertising identity the
|
|
174
|
+
* install enrichment resolved.
|
|
175
|
+
*/
|
|
176
|
+
public fun setAppInstanceId(id: String?) {
|
|
177
|
+
this.appInstanceId = id
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The first-open event, at most once per installation.
|
|
182
|
+
*
|
|
183
|
+
* Ordering is deliberate. The consent gate comes first, so a refused install
|
|
184
|
+
* leaves no flag and can still fire once consent arrives. The flag is written
|
|
185
|
+
* last, so a crash between the event and the flag costs a duplicate the
|
|
186
|
+
* backend dedup window absorbs — a permanently missing install is the worse
|
|
187
|
+
* failure.
|
|
188
|
+
*/
|
|
189
|
+
public fun trackInstall(properties: Map<String, Any?>? = null): Boolean {
|
|
190
|
+
if (forgotten || (config.requireConsent && !consent)) return false
|
|
191
|
+
if (store.getString(INSTALL_SENT_KEY) == "1") return false
|
|
192
|
+
queue.enqueue(newEvent(type = "install", name = "install", properties = properties))
|
|
193
|
+
store.setString(INSTALL_SENT_KEY, "1")
|
|
194
|
+
return true
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Erasure, including the install-scoped identifiers.
|
|
199
|
+
*
|
|
200
|
+
* They are wiped HERE rather than by the facade, and the ordering is the
|
|
201
|
+
* reason. The facade's caller runs on its own thread while the pipe may still
|
|
202
|
+
* be holding a queued `trackInstall`; a wipe performed outside the pipe races
|
|
203
|
+
* that command and the flag is written back after the erasure. Routing it
|
|
204
|
+
* through the same ordered ingress puts the wipe behind everything already
|
|
205
|
+
* submitted.
|
|
206
|
+
*
|
|
207
|
+
* The store routes these three keys to `noBackupFilesDir` — see
|
|
208
|
+
* `CompositeStore`. Clearing only the preferences half is the layering trap
|
|
209
|
+
* the spec recorded: an erasure that leaves the identifiers behind and looks
|
|
210
|
+
* finished.
|
|
211
|
+
*/
|
|
212
|
+
public fun forgetMe() {
|
|
213
|
+
forgotten = true
|
|
214
|
+
queue.clear()
|
|
215
|
+
sessions.reset()
|
|
216
|
+
consent = false
|
|
217
|
+
consentData = null
|
|
218
|
+
pushToken = null
|
|
219
|
+
pushProvider = null
|
|
220
|
+
customerUserId = null
|
|
221
|
+
idfa = null
|
|
222
|
+
gaid = null
|
|
223
|
+
androidId = null
|
|
224
|
+
appInstanceId = null
|
|
225
|
+
for (key in
|
|
226
|
+
listOf(
|
|
227
|
+
CONSENT_KEY,
|
|
228
|
+
CONSENT_DATA_KEY,
|
|
229
|
+
SESSION_STATE_KEY,
|
|
230
|
+
USER_ID_KEY,
|
|
231
|
+
DEVICE_ID_KEY,
|
|
232
|
+
INSTALLATION_ID_KEY,
|
|
233
|
+
INSTALL_SENT_KEY,
|
|
234
|
+
)) {
|
|
235
|
+
store.remove(key)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
public fun notifyForeground() {
|
|
240
|
+
if (forgotten || (config.requireConsent && !consent)) return
|
|
241
|
+
sessions.handleForeground().forEach { track(it.name, it.properties, type = "session") }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
public fun notifyBackground() {
|
|
245
|
+
if (forgotten || (config.requireConsent && !consent)) return
|
|
246
|
+
sessions.handleBackground()?.let { track(it.name, it.properties, type = "session") }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
public fun pendingEventIds(): List<String> = queue.peek(Int.MAX_VALUE).map { it.id }
|
|
250
|
+
|
|
251
|
+
/** Test surface: the ids alone cannot say what a field carries. */
|
|
252
|
+
public fun pendingEvents(): List<ClientEvent> = queue.peek(Int.MAX_VALUE)
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Flush.
|
|
256
|
+
//
|
|
257
|
+
// The DECISION logic below runs on the engine's thread and nowhere else; the
|
|
258
|
+
// network call is the only part that may run elsewhere. That split is why the
|
|
259
|
+
// three steps are separate methods rather than one loop: `CommandPipe` drives
|
|
260
|
+
// them across two threads so a stalled request cannot block the commands
|
|
261
|
+
// queued behind it, while `flush()` drives the same three methods inline for
|
|
262
|
+
// callers and for the vectors. One implementation of the decisions, two
|
|
263
|
+
// drivers — a second copy is exactly the drift the vectors exist to prevent.
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
private var inFlight: List<ClientEvent>? = null
|
|
267
|
+
private var isolationCursor: MutableList<ClientEvent>? = null
|
|
268
|
+
|
|
269
|
+
/** Whether a transport is wired at all. */
|
|
270
|
+
internal fun canFlush(): Boolean = transport != null
|
|
271
|
+
|
|
272
|
+
/** Decides what to send. Null means there is nothing to do. */
|
|
273
|
+
internal fun beginFlush(): List<ClientEvent>? {
|
|
274
|
+
if (transport == null || flushing || queue.size == 0 || clock.nowMs() < backoffUntilMs) {
|
|
275
|
+
return null
|
|
276
|
+
}
|
|
277
|
+
// Clamped to the wire's limit, not trusted. The server answers 400 for a
|
|
278
|
+
// larger batch and a 400 is not retryable, so an app that set 200 would
|
|
279
|
+
// have every batch rejected and then re-sent one event at a time by the
|
|
280
|
+
// poison-isolation path: nothing lost, and every flush costing 1 + N
|
|
281
|
+
// requests forever. The constant said 100 and enforced nothing until now.
|
|
282
|
+
val events = queue.peek(minOf(config.batchSize, MAX_BATCH_SIZE))
|
|
283
|
+
if (events.isEmpty()) return null
|
|
284
|
+
flushing = true
|
|
285
|
+
inFlight = events
|
|
286
|
+
return events
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Consumes the outcome of the batch send. Returns the first event to isolate,
|
|
291
|
+
* or null when the flush is finished.
|
|
292
|
+
*/
|
|
293
|
+
internal fun onBatchOutcome(error: Throwable?): ClientEvent? {
|
|
294
|
+
val events = inFlight ?: return null
|
|
295
|
+
if (error == null) {
|
|
296
|
+
queue.ack(events)
|
|
297
|
+
consecutiveFailures = 0
|
|
298
|
+
backoffUntilMs = 0L
|
|
299
|
+
endFlush()
|
|
300
|
+
return null
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
val ingest = error as? IngestError
|
|
304
|
+
if (ingest != null && !ingest.isRetryable) {
|
|
305
|
+
// Poison payload. Ingest parses a batch as a whole and answers one 400 for
|
|
306
|
+
// all of it, so the offender has to be found rather than the batch
|
|
307
|
+
// discarded. A 4xx is not an outage, so it does not arm the backoff.
|
|
308
|
+
onError("flush.poison", error)
|
|
309
|
+
val cursor = events.toMutableList()
|
|
310
|
+
isolationCursor = cursor
|
|
311
|
+
return cursor.removeAt(0)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Transient (network, 5xx, 429): keep the batch and back off, so a fleet
|
|
315
|
+
// recovering from an outage does not retry in lockstep.
|
|
316
|
+
onError("flush.transport", error)
|
|
317
|
+
armBackoff()
|
|
318
|
+
endFlush()
|
|
319
|
+
return null
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Consumes the outcome of one isolated event and returns the next, or null
|
|
324
|
+
* when isolation is over. Stops at the first transient error, so a network
|
|
325
|
+
* drop mid-isolation cannot turn deliverable events into dropped ones.
|
|
326
|
+
*/
|
|
327
|
+
internal fun onSingleOutcome(event: ClientEvent, error: Throwable?): ClientEvent? {
|
|
328
|
+
if (error == null) {
|
|
329
|
+
queue.ack(listOf(event))
|
|
330
|
+
} else {
|
|
331
|
+
val ingest = error as? IngestError
|
|
332
|
+
if (ingest == null || ingest.isRetryable) {
|
|
333
|
+
endFlush()
|
|
334
|
+
return null
|
|
335
|
+
}
|
|
336
|
+
queue.ack(listOf(event))
|
|
337
|
+
onDrop(listOf(event))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
val cursor = isolationCursor
|
|
341
|
+
if (cursor == null || cursor.isEmpty()) {
|
|
342
|
+
endFlush()
|
|
343
|
+
return null
|
|
344
|
+
}
|
|
345
|
+
return cursor.removeAt(0)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private fun endFlush() {
|
|
349
|
+
flushing = false
|
|
350
|
+
inFlight = null
|
|
351
|
+
isolationCursor = null
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Inline driver: sends on the calling thread. Used by callers without a pipe
|
|
356
|
+
* and by the conformance vectors. Never throws — a timer-driven call is not
|
|
357
|
+
* awaited, so a transient failure simply leaves the batch buffered.
|
|
358
|
+
*/
|
|
359
|
+
public fun flush() {
|
|
360
|
+
val batch = beginFlush() ?: return
|
|
361
|
+
var next = onBatchOutcome(send(batch))
|
|
362
|
+
while (next != null) {
|
|
363
|
+
next = onSingleOutcome(next, send(listOf(next)))
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The single network call, returning its failure rather than throwing. */
|
|
368
|
+
internal fun send(events: List<ClientEvent>): Throwable? =
|
|
369
|
+
try {
|
|
370
|
+
transport?.send(events)
|
|
371
|
+
null
|
|
372
|
+
} catch (error: Throwable) {
|
|
373
|
+
error
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private fun armBackoff() {
|
|
377
|
+
consecutiveFailures += 1
|
|
378
|
+
val delay =
|
|
379
|
+
backoffDelayMs(consecutiveFailures, config.retryBaseMs, config.retryCapMs, random())
|
|
380
|
+
backoffUntilMs = clock.nowMs() + delay.toLong()
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Test surface: the `flush/` vectors assert that a 4xx leaves this at zero. */
|
|
384
|
+
public fun consecutiveFailureCount(): Int = consecutiveFailures
|
|
385
|
+
|
|
386
|
+
private fun newEvent(
|
|
387
|
+
type: String,
|
|
388
|
+
name: String,
|
|
389
|
+
properties: Map<String, Any?>?,
|
|
390
|
+
): ClientEvent =
|
|
391
|
+
ClientEvent(
|
|
392
|
+
id = uuid.next(),
|
|
393
|
+
deviceId = config.deviceId,
|
|
394
|
+
type = type,
|
|
395
|
+
name = name,
|
|
396
|
+
timestamp = iso8601(clock.nowMs()),
|
|
397
|
+
platform = config.platform,
|
|
398
|
+
installationId = config.installationId,
|
|
399
|
+
appVersion = config.appVersion,
|
|
400
|
+
osVersion = config.osVersion,
|
|
401
|
+
sdkVersion = config.sdkVersion,
|
|
402
|
+
customerUserId = customerUserId,
|
|
403
|
+
idfa = idfa,
|
|
404
|
+
gaid = gaid,
|
|
405
|
+
androidId = androidId,
|
|
406
|
+
appInstanceId = appInstanceId,
|
|
407
|
+
consent = consentData?.toMap(),
|
|
408
|
+
// Lifecycle events only. A push token is ~180 bytes and the registry
|
|
409
|
+
// needs it periodically, not on every custom event in a 100-event batch.
|
|
410
|
+
pushToken = if (type == "install" || type == "session") pushToken else null,
|
|
411
|
+
pushProvider = if (type == "install" || type == "session") pushProvider else null,
|
|
412
|
+
properties = properties,
|
|
413
|
+
)
|
|
414
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
package io.advenue.core
|
|
2
|
+
|
|
3
|
+
internal const val QUEUE_KEY: String = "advenue.queue"
|
|
4
|
+
|
|
5
|
+
/** Persist debounce window. Bursts inside it coalesce into one write. */
|
|
6
|
+
internal const val PERSIST_DEBOUNCE_MS: Int = 100
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Durable FIFO event buffer, mirroring sdk-core's `EventQueue` including its
|
|
10
|
+
* persisted blob shape — the RN inversion has to read what TypeScript wrote.
|
|
11
|
+
*
|
|
12
|
+
* Persist strategy: `enqueue` debounces so a burst costs one write; `ack` and
|
|
13
|
+
* `clear` are flush boundaries and write synchronously, because stale storage
|
|
14
|
+
* at those points means event loss on ack or a double send after a crash.
|
|
15
|
+
*
|
|
16
|
+
* Confined to the engine's single thread. Not thread-safe, and deliberately so:
|
|
17
|
+
* a lock here would hide a confinement mistake rather than prevent one.
|
|
18
|
+
*/
|
|
19
|
+
internal class EventQueue(
|
|
20
|
+
private val store: KeyValueStore,
|
|
21
|
+
private val maxSize: Int = 10_000,
|
|
22
|
+
private val scheduler: Scheduler,
|
|
23
|
+
) {
|
|
24
|
+
private val events: MutableList<ClientEvent> = load(store).toMutableList()
|
|
25
|
+
private var persistToken: CancelToken? = null
|
|
26
|
+
|
|
27
|
+
public val size: Int
|
|
28
|
+
get() = events.size
|
|
29
|
+
|
|
30
|
+
public fun enqueue(event: ClientEvent) {
|
|
31
|
+
events.add(event)
|
|
32
|
+
// Bound the buffer: a long offline period on a chatty app must not grow the
|
|
33
|
+
// persisted blob without limit. Recent events are the ones worth keeping.
|
|
34
|
+
while (events.size > maxSize) events.removeAt(0)
|
|
35
|
+
schedulePersist()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public fun peek(max: Int): List<ClientEvent> = events.take(max)
|
|
39
|
+
|
|
40
|
+
public fun ack(sent: List<ClientEvent>) {
|
|
41
|
+
if (sent.isEmpty()) return
|
|
42
|
+
val ids = sent.map { it.id }.toHashSet()
|
|
43
|
+
events.removeAll { it.id in ids }
|
|
44
|
+
forcePersist()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public fun clear() {
|
|
48
|
+
events.clear()
|
|
49
|
+
forcePersist()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private fun schedulePersist() {
|
|
53
|
+
if (persistToken != null) return
|
|
54
|
+
persistToken =
|
|
55
|
+
scheduler.schedule(PERSIST_DEBOUNCE_MS) {
|
|
56
|
+
persistToken = null
|
|
57
|
+
write()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private fun forcePersist() {
|
|
62
|
+
persistToken?.let {
|
|
63
|
+
scheduler.cancel(it)
|
|
64
|
+
persistToken = null
|
|
65
|
+
}
|
|
66
|
+
write()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private fun write() {
|
|
70
|
+
try {
|
|
71
|
+
store.setString(QUEUE_KEY, writeCanonicalJson(events.map { it.toMap() }))
|
|
72
|
+
} catch (_: Exception) {
|
|
73
|
+
// A failed write costs the offline buffer, not the process. The in-memory
|
|
74
|
+
// list stays authoritative for this launch.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private companion object {
|
|
79
|
+
/**
|
|
80
|
+
* A corrupt blob loads as empty rather than throwing: bricking the SDK on
|
|
81
|
+
* every launch is worse than losing an offline buffer.
|
|
82
|
+
*/
|
|
83
|
+
private fun load(store: KeyValueStore): List<ClientEvent> {
|
|
84
|
+
val raw = store.getString(QUEUE_KEY) ?: return emptyList()
|
|
85
|
+
val parsed = parseJson(raw) as? List<*> ?: return emptyList()
|
|
86
|
+
return parsed.mapNotNull { (it as? Map<*, *>)?.let(ClientEvent::fromMap) }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package io.advenue.core
|
|
2
|
+
|
|
3
|
+
import javax.crypto.Mac
|
|
4
|
+
import javax.crypto.spec.SecretKeySpec
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Production signing. `javax.crypto` is part of every JDK and every Android
|
|
8
|
+
* runtime, so unlike the Swift port — where `AdvenueCore` had to stay free of a
|
|
9
|
+
* crypto library to build on Linux — the core can own this outright. One HMAC
|
|
10
|
+
* implementation, not two.
|
|
11
|
+
*/
|
|
12
|
+
internal class JvmHmacSigner : Signer {
|
|
13
|
+
override fun hmacSha256Hex(secret: String, message: String): String {
|
|
14
|
+
val mac = Mac.getInstance("HmacSHA256")
|
|
15
|
+
mac.init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256"))
|
|
16
|
+
// UTF-8, not UTF-16: TextEncoder in the TypeScript original encodes UTF-8,
|
|
17
|
+
// and a port hashing UTF-16 code units would agree on every ASCII body and
|
|
18
|
+
// diverge on exactly the ones with emoji in them.
|
|
19
|
+
val digest = mac.doFinal(message.toByteArray(Charsets.UTF_8))
|
|
20
|
+
val out = StringBuilder(digest.size * 2)
|
|
21
|
+
for (byte in digest) {
|
|
22
|
+
out.append(HEX[(byte.toInt() shr 4) and 0xF])
|
|
23
|
+
out.append(HEX[byte.toInt() and 0xF])
|
|
24
|
+
}
|
|
25
|
+
return out.toString()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private companion object {
|
|
29
|
+
private val HEX = "0123456789abcdef".toCharArray()
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -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
|
+
}
|