@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,549 @@
|
|
|
1
|
+
package io.advenue
|
|
2
|
+
|
|
3
|
+
import android.app.Application
|
|
4
|
+
import android.content.BroadcastReceiver
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.Intent
|
|
7
|
+
import android.content.IntentFilter
|
|
8
|
+
import android.os.Build
|
|
9
|
+
import io.advenue.core.AdvenueEngine
|
|
10
|
+
import io.advenue.core.Command
|
|
11
|
+
import io.advenue.core.CommandPipe
|
|
12
|
+
import io.advenue.core.Consent
|
|
13
|
+
import io.advenue.core.DeepLink
|
|
14
|
+
import io.advenue.core.resolveDeferredDeepLink
|
|
15
|
+
import io.advenue.core.EngineConfig
|
|
16
|
+
import io.advenue.core.EventTransport
|
|
17
|
+
import io.advenue.core.IngestError
|
|
18
|
+
import io.advenue.core.SystemClock
|
|
19
|
+
import io.advenue.core.SystemUuids
|
|
20
|
+
import io.advenue.core.TimerScheduler
|
|
21
|
+
import io.advenue.platform.HttpConversionFetcher
|
|
22
|
+
import io.advenue.platform.HttpUrlTransport
|
|
23
|
+
import io.advenue.platform.IdentityResolution
|
|
24
|
+
import io.advenue.platform.EnrichmentSources
|
|
25
|
+
import io.advenue.platform.InstallScopedStore
|
|
26
|
+
import io.advenue.platform.LifecycleBridge
|
|
27
|
+
import io.advenue.platform.CompositeStore
|
|
28
|
+
import io.advenue.platform.PreferencesStore
|
|
29
|
+
import io.advenue.core.extractAemCampaignIds
|
|
30
|
+
import io.advenue.core.tcfToConsent
|
|
31
|
+
import io.advenue.platform.collectDeviceInfo
|
|
32
|
+
import io.advenue.platform.readTcf
|
|
33
|
+
import io.advenue.platform.collectEnrichment
|
|
34
|
+
import io.advenue.platform.sha256Hex
|
|
35
|
+
import io.advenue.platform.resolveIdentity
|
|
36
|
+
import io.advenue.platform.systemUnlockProbe
|
|
37
|
+
import java.util.Timer
|
|
38
|
+
import java.util.TimerTask
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The public SDK. A static facade matching the React Native and iOS SDKs' names,
|
|
42
|
+
* so a developer moving between them reads the same API, delegating to state
|
|
43
|
+
* that is replaceable and inspectable rather than to a hidden singleton.
|
|
44
|
+
*/
|
|
45
|
+
public object Advenue {
|
|
46
|
+
private val state = FacadeState()
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Starts the SDK. Safe to call from `Application.onCreate`, and safe to call
|
|
50
|
+
* twice: the previous instance is shut down first. Without that, a second call
|
|
51
|
+
* would leave two consumer threads draining one queue, so events would be
|
|
52
|
+
* processed twice or lost.
|
|
53
|
+
*
|
|
54
|
+
* Takes the `Application` explicitly. ContentProvider auto-initialisation —
|
|
55
|
+
* the Firebase pattern — is rejected: it charges every cold start and hides
|
|
56
|
+
* initialisation order.
|
|
57
|
+
*/
|
|
58
|
+
@JvmStatic
|
|
59
|
+
public fun initialize(application: Application, config: AdvenueConfig) {
|
|
60
|
+
state.start(application, config)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Records an event. Synchronous, non-blocking and ordered. */
|
|
64
|
+
@JvmStatic
|
|
65
|
+
@JvmOverloads
|
|
66
|
+
public fun track(name: String, properties: Map<String, Any?>? = null) {
|
|
67
|
+
state.submit(Command.Track(name, properties, "custom"))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@JvmStatic
|
|
71
|
+
public fun setUserId(id: String?) {
|
|
72
|
+
state.submit(Command.SetUserId(id))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Granular ad-platform consent (Google DMA), forwarded by server-side
|
|
77
|
+
* postbacks as gdpr_applies / ad_user_data / ad_personalization / ad_storage.
|
|
78
|
+
*
|
|
79
|
+
* Leave a field null when the user has not been asked: "not stated" is not
|
|
80
|
+
* "denied", and inventing false on their behalf records a refusal that never
|
|
81
|
+
* happened.
|
|
82
|
+
*/
|
|
83
|
+
@JvmStatic
|
|
84
|
+
@JvmOverloads
|
|
85
|
+
public fun setConsentData(
|
|
86
|
+
isUserSubjectToGDPR: Boolean,
|
|
87
|
+
hasConsentForDataUsage: Boolean? = null,
|
|
88
|
+
hasConsentForAdsPersonalization: Boolean? = null,
|
|
89
|
+
hasConsentForAdStorage: Boolean? = null,
|
|
90
|
+
) {
|
|
91
|
+
val consent =
|
|
92
|
+
Consent(
|
|
93
|
+
isUserSubjectToGDPR,
|
|
94
|
+
hasConsentForDataUsage,
|
|
95
|
+
hasConsentForAdsPersonalization,
|
|
96
|
+
hasConsentForAdStorage,
|
|
97
|
+
)
|
|
98
|
+
state.consentDataMirror = consent
|
|
99
|
+
state.submit(Command.SetConsentData(consent))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Registers the device's push token for uninstall measurement.
|
|
104
|
+
*
|
|
105
|
+
* Advenue never asks for the notification permission and never displays
|
|
106
|
+
* anything. Pass the token your push library already gives you, on every
|
|
107
|
+
* launch: the OS can rotate it, and a stale token is what makes uninstall
|
|
108
|
+
* measurement report churn that did not happen. Pass null to stop.
|
|
109
|
+
*/
|
|
110
|
+
@JvmStatic
|
|
111
|
+
@JvmOverloads
|
|
112
|
+
public fun setPushToken(token: String?, provider: String? = null) {
|
|
113
|
+
state.submit(Command.SetPushToken(token, provider))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The app the ingestion service resolved this API key to, or null until a
|
|
118
|
+
* batch has been accepted.
|
|
119
|
+
*
|
|
120
|
+
* The API key is the SDK's entire app identity, so pasting the wrong one is
|
|
121
|
+
* silent: events are still accepted, just recorded against another app, and
|
|
122
|
+
* every screen the integrator checks is the one they believe they configured.
|
|
123
|
+
* This is the answer to "which app am I actually writing to", read from the
|
|
124
|
+
* device rather than inferred from the dashboard.
|
|
125
|
+
*/
|
|
126
|
+
@JvmStatic public fun resolvedAppId(): String? = state.resolvedAppId
|
|
127
|
+
|
|
128
|
+
@JvmStatic
|
|
129
|
+
public fun setConsent(granted: Boolean) {
|
|
130
|
+
state.consentMirror = granted
|
|
131
|
+
state.submit(Command.SetConsent(granted))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whether tracking consent is currently granted. Persisted, so this is the
|
|
136
|
+
* answer after a restart too — a caller that defaulted to false on every cold
|
|
137
|
+
* start would silently discard a preference the user gave.
|
|
138
|
+
*/
|
|
139
|
+
@JvmStatic public fun trackingConsent(): Boolean = state.consentMirror
|
|
140
|
+
|
|
141
|
+
/** The granular DMA consent last set, or null if none has been. */
|
|
142
|
+
@JvmStatic public fun consentData(): Consent? = state.consentDataMirror
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The Firebase App Instance ID, when the app resolves it itself rather than
|
|
146
|
+
* through the `advenue-firebase` plugin. Does not disturb the advertising
|
|
147
|
+
* identity.
|
|
148
|
+
*/
|
|
149
|
+
@JvmStatic
|
|
150
|
+
public fun setAppInstanceId(id: String?) {
|
|
151
|
+
state.submit(Command.SetAppInstanceId(id))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Sends what is buffered. A no-op when the queue is empty or a backoff is open. */
|
|
155
|
+
@JvmStatic
|
|
156
|
+
public fun flush() {
|
|
157
|
+
state.submit(Command.Flush)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Forward from `onCreate` and `onNewIntent`. Callable **before** `initialize`:
|
|
162
|
+
* a cold start from a link runs the launcher activity first, and the links
|
|
163
|
+
* that carry attribution are precisely the ones that would be lost.
|
|
164
|
+
*
|
|
165
|
+
* `Intent.EXTRA_REFERRER` is deliberately not read here — it is not the Play
|
|
166
|
+
* install referrer, and conflating them would make a warm-start deep link look
|
|
167
|
+
* like one.
|
|
168
|
+
*/
|
|
169
|
+
@JvmStatic
|
|
170
|
+
public fun processDeepLink(intent: Intent?) {
|
|
171
|
+
intent?.data?.toString()?.let { state.deepLink(it) }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Erasure. Spans both stores — see [FacadeState.forgetMe]. */
|
|
175
|
+
@JvmStatic
|
|
176
|
+
public fun forgetMe() {
|
|
177
|
+
state.forgetMe()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The device identifier, or **null** while identity is deferred.
|
|
182
|
+
*
|
|
183
|
+
* sdk-core's getter is non-null; this one is not, and that is a real API
|
|
184
|
+
* difference rather than an oversight. Identity can be deferred while
|
|
185
|
+
* credential-encrypted storage is unreadable (Direct Boot), and blocking until
|
|
186
|
+
* it resolves could block indefinitely. Returning an invented id is the
|
|
187
|
+
* phantom-device bug the design exists to prevent.
|
|
188
|
+
*/
|
|
189
|
+
@JvmStatic public fun deviceId(): String? = state.currentDeviceId
|
|
190
|
+
|
|
191
|
+
@JvmStatic public fun pendingCount(): Int = state.pendingCount()
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Resolves the deferred deep link for this install, or null for an organic
|
|
195
|
+
* one — which is most of them. Safe to call once on first launch; the app
|
|
196
|
+
* routes on the result.
|
|
197
|
+
*
|
|
198
|
+
* Blocking, and deliberately so: it polls with backoff, and hiding that
|
|
199
|
+
* behind a callback would invite calling it on the main thread. Run it on
|
|
200
|
+
* your own background thread.
|
|
201
|
+
*/
|
|
202
|
+
@JvmStatic public fun resolveDeferredDeepLink(): DeepLink? = state.resolveDeferredDeepLink()
|
|
203
|
+
|
|
204
|
+
@JvmStatic
|
|
205
|
+
public fun shutdown() {
|
|
206
|
+
state.stop()
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Holds what a static facade cannot: the live engine, the command pipe, the
|
|
212
|
+
* pre-init deep-link buffer and the resolved identity.
|
|
213
|
+
*/
|
|
214
|
+
internal class FacadeState {
|
|
215
|
+
private val lock = Any()
|
|
216
|
+
private var pipe: CommandPipe? = null
|
|
217
|
+
private var engine: AdvenueEngine? = null
|
|
218
|
+
private var scoped: InstallScopedStore? = null
|
|
219
|
+
private var prefs: PreferencesStore? = null
|
|
220
|
+
private var bridge: LifecycleBridge? = null
|
|
221
|
+
private var flushTimer: Timer? = null
|
|
222
|
+
private var unlockReceiver: BroadcastReceiver? = null
|
|
223
|
+
private var app: Application? = null
|
|
224
|
+
private var pendingDeepLinks = mutableListOf<String>()
|
|
225
|
+
private val seenAemUrlHashes = mutableSetOf<String>()
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Read caches for the two consent values. The engine owns persistence; these
|
|
229
|
+
* exist so a synchronous getter can answer without a round trip through the
|
|
230
|
+
* pipe, and so a caller reading back its own `setConsent` never sees the
|
|
231
|
+
* value it just replaced.
|
|
232
|
+
*/
|
|
233
|
+
@Volatile internal var consentMirror: Boolean = false
|
|
234
|
+
|
|
235
|
+
@Volatile internal var consentDataMirror: Consent? = null
|
|
236
|
+
|
|
237
|
+
@Volatile private var resolvedDeviceId: String? = null
|
|
238
|
+
|
|
239
|
+
/** Set by the transport after a batch is accepted; diagnostics only. */
|
|
240
|
+
@Volatile internal var resolvedAppId: String? = null
|
|
241
|
+
private var startedEndpoint: String? = null
|
|
242
|
+
private var startedApiKey: String? = null
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* `transport` is a parameter, not a hidden construction, because an unwired
|
|
246
|
+
* transport is otherwise invisible: every component of the send path can be
|
|
247
|
+
* green while nothing joins them. That is the defect the iOS SDK shipped with,
|
|
248
|
+
* and `FacadeSeamTest` injects a recorder here to make sure this one cannot.
|
|
249
|
+
*/
|
|
250
|
+
fun start(
|
|
251
|
+
application: Application,
|
|
252
|
+
config: AdvenueConfig,
|
|
253
|
+
transport: EventTransport? = null,
|
|
254
|
+
sources: EnrichmentSources? = null,
|
|
255
|
+
) {
|
|
256
|
+
// Replace-and-shut-down, never add.
|
|
257
|
+
stop()
|
|
258
|
+
|
|
259
|
+
val scoped = InstallScopedStore(application)
|
|
260
|
+
val prefs = PreferencesStore(application)
|
|
261
|
+
val uuid = SystemUuids()
|
|
262
|
+
|
|
263
|
+
val identity = resolveIdentity(scoped, systemUnlockProbe(application), uuid)
|
|
264
|
+
if (identity !is IdentityResolution.Resolved) {
|
|
265
|
+
// Deferred: credential-encrypted storage is unreadable. Nothing is minted
|
|
266
|
+
// and nothing starts, so no event carries an invented identifier. The
|
|
267
|
+
// receiver below is the exit — without it the deferral would trade a
|
|
268
|
+
// phantom id for permanent silence.
|
|
269
|
+
config.onError("identity.deferred", IngestError(0))
|
|
270
|
+
registerUnlockRetry(application, config, transport, sources)
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
val eventTransport =
|
|
275
|
+
transport
|
|
276
|
+
?: HttpUrlTransport(
|
|
277
|
+
endpoint = config.endpoint,
|
|
278
|
+
apiKey = config.apiKey,
|
|
279
|
+
signingSecret = config.signingSecret,
|
|
280
|
+
clock = SystemClock(),
|
|
281
|
+
// Diagnostics only: it runs after the batch is already accepted, so
|
|
282
|
+
// nothing it does can turn a successful ingest into a failure.
|
|
283
|
+
onAccepted = { appId -> resolvedAppId = appId },
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
val scheduler = TimerScheduler()
|
|
287
|
+
val engine =
|
|
288
|
+
AdvenueEngine(
|
|
289
|
+
config =
|
|
290
|
+
EngineConfig(
|
|
291
|
+
apiKey = config.apiKey,
|
|
292
|
+
platform = "android",
|
|
293
|
+
deviceId = identity.deviceId,
|
|
294
|
+
installationId = identity.installationId,
|
|
295
|
+
appVersion = config.appVersion,
|
|
296
|
+
osVersion = Build.VERSION.RELEASE,
|
|
297
|
+
sdkVersion = (config.sdkVersion ?: AdvenueVersion.CURRENT).take(32),
|
|
298
|
+
requireConsent = config.requireConsent,
|
|
299
|
+
sessionWindowMs = config.sessionWindowMs,
|
|
300
|
+
batchSize = config.batchSize,
|
|
301
|
+
),
|
|
302
|
+
store = CompositeStore(prefs, scoped),
|
|
303
|
+
clock = SystemClock(),
|
|
304
|
+
scheduler = scheduler,
|
|
305
|
+
uuid = uuid,
|
|
306
|
+
transport = eventTransport,
|
|
307
|
+
onError = config.onError,
|
|
308
|
+
)
|
|
309
|
+
val pipe = CommandPipe(engine, config.onError)
|
|
310
|
+
// The debounce timer may only WAKE; the work itself touches the event list,
|
|
311
|
+
// which belongs to the engine's thread.
|
|
312
|
+
scheduler.dispatcher = { work -> pipe.submit(Command.RunScheduled(work)) }
|
|
313
|
+
|
|
314
|
+
val buffered: List<String>
|
|
315
|
+
synchronized(lock) {
|
|
316
|
+
this.app = application
|
|
317
|
+
this.scoped = scoped
|
|
318
|
+
this.prefs = prefs
|
|
319
|
+
this.engine = engine
|
|
320
|
+
// Seeded from the same persisted values the engine loads, so consent
|
|
321
|
+
// granted in a previous run is still granted after a cold start.
|
|
322
|
+
this.consentMirror = engine.getConsent()
|
|
323
|
+
this.consentDataMirror = engine.getConsentData()
|
|
324
|
+
this.pipe = pipe
|
|
325
|
+
this.resolvedDeviceId = identity.deviceId
|
|
326
|
+
this.startedEndpoint = config.endpoint
|
|
327
|
+
this.startedApiKey = config.apiKey
|
|
328
|
+
buffered = pendingDeepLinks.toList()
|
|
329
|
+
pendingDeepLinks = mutableListOf()
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Replayed in arrival order, before the first session, so a deferred deep
|
|
333
|
+
// link is attributed to the launch it belongs to.
|
|
334
|
+
buffered.forEach { sendDeepLink(it) }
|
|
335
|
+
|
|
336
|
+
val bridge =
|
|
337
|
+
LifecycleBridge(
|
|
338
|
+
application,
|
|
339
|
+
onForeground = { pipe.submit(Command.Foreground) },
|
|
340
|
+
onBackground = {
|
|
341
|
+
pipe.submit(Command.Background)
|
|
342
|
+
// A batch stranded when the app leaves the foreground may not be sent
|
|
343
|
+
// for hours.
|
|
344
|
+
pipe.submit(Command.Flush)
|
|
345
|
+
},
|
|
346
|
+
)
|
|
347
|
+
bridge.start()
|
|
348
|
+
// Application.onCreate runs before the first activity starts, so the
|
|
349
|
+
// foreground signal arrives from the bridge; a cold start where the SDK is
|
|
350
|
+
// initialised from an already-running activity still needs this.
|
|
351
|
+
pipe.submit(Command.Foreground)
|
|
352
|
+
|
|
353
|
+
// Enrichment then install, off the caller's thread. initialize() returns
|
|
354
|
+
// synchronously — an SDK that blocks Application.onCreate for five seconds
|
|
355
|
+
// is one nobody ships.
|
|
356
|
+
val installSources = sources ?: EnrichmentSources.discovered()
|
|
357
|
+
Thread(
|
|
358
|
+
{
|
|
359
|
+
val enrichment =
|
|
360
|
+
collectEnrichment(
|
|
361
|
+
application,
|
|
362
|
+
identity.deviceId,
|
|
363
|
+
installSources,
|
|
364
|
+
fbAppId = config.fbAppId,
|
|
365
|
+
)
|
|
366
|
+
pipe.submit(
|
|
367
|
+
Command.SetIdentity(
|
|
368
|
+
gaid = enrichment.gaid,
|
|
369
|
+
androidId = null,
|
|
370
|
+
appInstanceId = enrichment.appInstanceId,
|
|
371
|
+
),
|
|
372
|
+
)
|
|
373
|
+
// Device metadata rides the install, where Meta CAPI reads it.
|
|
374
|
+
// Merged under the referrer properties: an attribution field wins
|
|
375
|
+
// over a descriptive one if they ever collide.
|
|
376
|
+
val installProperties =
|
|
377
|
+
collectDeviceInfo(application) +
|
|
378
|
+
(enrichment.referrerProperties ?: emptyMap()) +
|
|
379
|
+
(enrichment.metaProperties ?: emptyMap())
|
|
380
|
+
// The CMP writes TCF to the platform's default preferences, and
|
|
381
|
+
// reading it is the difference between shipping a real consent
|
|
382
|
+
// signal and shipping none. Submitted before the install so the
|
|
383
|
+
// first event carries it.
|
|
384
|
+
readTcf(application)?.let { tcf ->
|
|
385
|
+
tcfToConsent(
|
|
386
|
+
tcf["gdprApplies"] as? Int,
|
|
387
|
+
tcf["purposeConsents"] as? String,
|
|
388
|
+
)
|
|
389
|
+
?.let { pipe.submit(Command.SetConsentData(it)) }
|
|
390
|
+
}
|
|
391
|
+
pipe.submit(Command.TrackInstall(installProperties))
|
|
392
|
+
pipe.submit(Command.Flush)
|
|
393
|
+
},
|
|
394
|
+
"advenue-install",
|
|
395
|
+
)
|
|
396
|
+
.apply { isDaemon = true }
|
|
397
|
+
.start()
|
|
398
|
+
|
|
399
|
+
val timer =
|
|
400
|
+
if (config.flushIntervalMs > 0) {
|
|
401
|
+
Timer("advenue-flush", true).also {
|
|
402
|
+
it.scheduleAtFixedRate(
|
|
403
|
+
object : TimerTask() {
|
|
404
|
+
override fun run() {
|
|
405
|
+
pipe.submit(Command.Flush)
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
config.flushIntervalMs,
|
|
409
|
+
config.flushIntervalMs,
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
null
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
synchronized(lock) {
|
|
417
|
+
this.bridge = bridge
|
|
418
|
+
this.flushTimer = timer
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The exit from a deferred identity. Registered only when identity could not
|
|
424
|
+
* be resolved; unregistered on the first successful start.
|
|
425
|
+
*/
|
|
426
|
+
private fun registerUnlockRetry(
|
|
427
|
+
application: Application,
|
|
428
|
+
config: AdvenueConfig,
|
|
429
|
+
transport: EventTransport?,
|
|
430
|
+
sources: EnrichmentSources?,
|
|
431
|
+
) {
|
|
432
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
|
|
433
|
+
val receiver =
|
|
434
|
+
object : BroadcastReceiver() {
|
|
435
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
436
|
+
runCatching { application.unregisterReceiver(this) }
|
|
437
|
+
synchronized(lock) { unlockReceiver = null }
|
|
438
|
+
start(application, config, transport, sources)
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
runCatching {
|
|
442
|
+
application.registerReceiver(receiver, IntentFilter(Intent.ACTION_USER_UNLOCKED))
|
|
443
|
+
synchronized(lock) { unlockReceiver = receiver }
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
fun submit(command: Command) {
|
|
448
|
+
synchronized(lock) { pipe }?.submit(command)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
fun deepLink(url: String) {
|
|
452
|
+
val started = synchronized(lock) {
|
|
453
|
+
val running = pipe != null
|
|
454
|
+
if (!running) pendingDeepLinks.add(url)
|
|
455
|
+
running
|
|
456
|
+
}
|
|
457
|
+
if (started) sendDeepLink(url)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private fun sendDeepLink(url: String) {
|
|
461
|
+
submit(Command.Track("deep_link", mapOf("url" to url), "custom"))
|
|
462
|
+
|
|
463
|
+
// Meta AEM: a link from Meta carries al_applink_data with an opaque,
|
|
464
|
+
// Meta-encrypted campaign_ids blob. Emitted at most once per URL — the
|
|
465
|
+
// same link re-opened is not a second measurement, and the server dedups
|
|
466
|
+
// on this hash too.
|
|
467
|
+
val applink = queryValue(url, "al_applink_data") ?: return
|
|
468
|
+
val campaignIds = extractAemCampaignIds(applink) ?: return
|
|
469
|
+
val hash = sha256Hex(url)
|
|
470
|
+
val fresh = synchronized(lock) { seenAemUrlHashes.add(hash) }
|
|
471
|
+
if (!fresh) return
|
|
472
|
+
submit(
|
|
473
|
+
Command.Track(
|
|
474
|
+
"adv_meta_aem",
|
|
475
|
+
mapOf("campaignIds" to campaignIds, "sourceUrlHash" to hash),
|
|
476
|
+
"custom",
|
|
477
|
+
),
|
|
478
|
+
)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Decoded value of one query parameter, or null. */
|
|
482
|
+
private fun queryValue(url: String, name: String): String? =
|
|
483
|
+
runCatching { android.net.Uri.parse(url).getQueryParameter(name) }.getOrNull()
|
|
484
|
+
|
|
485
|
+
/** Test surface: how many links are waiting for `initialize`. */
|
|
486
|
+
val bufferedDeepLinkCount: Int
|
|
487
|
+
get() = synchronized(lock) { pendingDeepLinks.size }
|
|
488
|
+
|
|
489
|
+
val currentDeviceId: String?
|
|
490
|
+
get() = resolvedDeviceId
|
|
491
|
+
|
|
492
|
+
fun pendingCount(): Int = synchronized(lock) { engine }?.pendingEventIds()?.size ?: 0
|
|
493
|
+
|
|
494
|
+
/** Polls the conversion lookup. Null for an organic install. */
|
|
495
|
+
fun resolveDeferredDeepLink(): DeepLink? {
|
|
496
|
+
val (endpoint, apiKey, deviceId) =
|
|
497
|
+
synchronized(lock) {
|
|
498
|
+
Triple(startedEndpoint, startedApiKey, resolvedDeviceId)
|
|
499
|
+
}
|
|
500
|
+
if (endpoint == null || apiKey == null || deviceId == null) return null
|
|
501
|
+
return resolveDeferredDeepLink(
|
|
502
|
+
HttpConversionFetcher(endpoint = endpoint, apiKey = apiKey, deviceId = deviceId))
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Erasure spans BOTH stores. The engine clears what lives in
|
|
507
|
+
* SharedPreferences; the install-scoped identifiers live in
|
|
508
|
+
* `noBackupFilesDir` and are wiped here. Calling only the engine's `forgetMe`
|
|
509
|
+
* is the layering trap the spec recorded — an erasure that leaves the
|
|
510
|
+
* identifiers behind, and looks finished.
|
|
511
|
+
*/
|
|
512
|
+
fun forgetMe() {
|
|
513
|
+
// Both stores are wiped by the engine, on the pipe's thread, so the wipe is
|
|
514
|
+
// ordered behind any command already queued — a wipe performed here would
|
|
515
|
+
// race a pending trackInstall and the flag would be written back after it.
|
|
516
|
+
submit(Command.ForgetMe)
|
|
517
|
+
synchronized(lock) { resolvedDeviceId = null }
|
|
518
|
+
// Erasure clears the read caches too: a getter still answering "granted"
|
|
519
|
+
// after forgetMe would report a consent the device no longer holds.
|
|
520
|
+
consentMirror = false
|
|
521
|
+
consentDataMirror = null
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
fun stop() {
|
|
525
|
+
val (pipe, bridge, timer, receiver, application) =
|
|
526
|
+
synchronized(lock) {
|
|
527
|
+
val snapshot =
|
|
528
|
+
Quintuple(this.pipe, this.bridge, this.flushTimer, this.unlockReceiver, this.app)
|
|
529
|
+
this.pipe = null
|
|
530
|
+
this.engine = null
|
|
531
|
+
this.bridge = null
|
|
532
|
+
this.flushTimer = null
|
|
533
|
+
this.unlockReceiver = null
|
|
534
|
+
snapshot
|
|
535
|
+
}
|
|
536
|
+
timer?.cancel()
|
|
537
|
+
bridge?.stop()
|
|
538
|
+
receiver?.let { r -> application?.let { runCatching { it.unregisterReceiver(r) } } }
|
|
539
|
+
pipe?.shutdown()
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private data class Quintuple(
|
|
543
|
+
val pipe: CommandPipe?,
|
|
544
|
+
val bridge: LifecycleBridge?,
|
|
545
|
+
val timer: Timer?,
|
|
546
|
+
val receiver: BroadcastReceiver?,
|
|
547
|
+
val app: Application?,
|
|
548
|
+
)
|
|
549
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
package io.advenue
|
|
2
|
+
|
|
3
|
+
import io.advenue.core.DEFAULT_SESSION_WINDOW_MS
|
|
4
|
+
import io.advenue.platform.DEFAULT_ENDPOINT
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SDK configuration. Defaults match sdk-core exactly, so an app moving from the
|
|
8
|
+
* React Native SDK gets the same behaviour without restating anything.
|
|
9
|
+
*/
|
|
10
|
+
public data class AdvenueConfig
|
|
11
|
+
@JvmOverloads
|
|
12
|
+
constructor(
|
|
13
|
+
val apiKey: String,
|
|
14
|
+
val endpoint: String = DEFAULT_ENDPOINT,
|
|
15
|
+
val appVersion: String? = null,
|
|
16
|
+
val requireConsent: Boolean = false,
|
|
17
|
+
val sessionWindowMs: Long = DEFAULT_SESSION_WINDOW_MS,
|
|
18
|
+
val batchSize: Int = 20,
|
|
19
|
+
/** Auto-flush period. Zero disables the timer, which is what tests want and no
|
|
20
|
+
* shipping app does. */
|
|
21
|
+
val flushIntervalMs: Long = 15_000,
|
|
22
|
+
/**
|
|
23
|
+
* Per-key HMAC secret. **Read this before setting it.** The signature provides
|
|
24
|
+
* integrity and replay protection, not authentication: anything shipped inside
|
|
25
|
+
* an app binary can be extracted from it, exactly as it can from a JavaScript
|
|
26
|
+
* bundle. Leaving it null sends unsigned requests, which the server accepts
|
|
27
|
+
* unless the app enforces signatures.
|
|
28
|
+
*/
|
|
29
|
+
val signingSecret: String? = null,
|
|
30
|
+
/** Called when the SDK swallows a best-effort failure. Never receives PII. */
|
|
31
|
+
val onError: (String, Throwable) -> Unit = { _, _ -> },
|
|
32
|
+
/**
|
|
33
|
+
* Overrides the version stamped on every event. Set by a WRAPPER SDK, never
|
|
34
|
+
* by an app.
|
|
35
|
+
*
|
|
36
|
+
* An event stamped with this SDK's own version says the same thing for every
|
|
37
|
+
* install and answers nothing. The useful answer is which wrapper produced it
|
|
38
|
+
* — a React Native or Flutter release pins the native snapshot inside it, so
|
|
39
|
+
* the wrapper's version identifies both. Adjust and AppsFlyer report the
|
|
40
|
+
* wrapper for the same reason.
|
|
41
|
+
*
|
|
42
|
+
* Capped at 32 characters by the ingest schema; a longer value would take the
|
|
43
|
+
* whole batch down with a 400, so it is truncated rather than sent.
|
|
44
|
+
*/
|
|
45
|
+
val sdkVersion: String? = null,
|
|
46
|
+
/**
|
|
47
|
+
* Meta app id from the Meta App Dashboard, enabling the Meta install
|
|
48
|
+
* referrer. Not a secret — the decryption key stays server-side and the
|
|
49
|
+
* device only ever carries ciphertext. Leave it null and no Meta content
|
|
50
|
+
* provider is queried at all.
|
|
51
|
+
*
|
|
52
|
+
* Android only: the Meta install referrer is a content-provider read, and
|
|
53
|
+
* iOS has no equivalent. A field on the Swift config would do nothing.
|
|
54
|
+
*/
|
|
55
|
+
val fbAppId: String? = null,
|
|
56
|
+
) {
|
|
57
|
+
/**
|
|
58
|
+
* Java-friendly builder. Kotlin's named and default arguments are invisible
|
|
59
|
+
* from Java, and both incumbents are Java-first — a Kotlin SDK that is painful
|
|
60
|
+
* from Java excludes a large share of Android apps.
|
|
61
|
+
*/
|
|
62
|
+
public class Builder(private val apiKey: String) {
|
|
63
|
+
private var config = AdvenueConfig(apiKey)
|
|
64
|
+
|
|
65
|
+
public fun endpoint(value: String): Builder = apply { config = config.copy(endpoint = value) }
|
|
66
|
+
|
|
67
|
+
public fun appVersion(value: String?): Builder = apply {
|
|
68
|
+
config = config.copy(appVersion = value)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public fun requireConsent(value: Boolean): Builder = apply {
|
|
72
|
+
config = config.copy(requireConsent = value)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public fun sessionWindowMs(value: Long): Builder = apply {
|
|
76
|
+
config = config.copy(sessionWindowMs = value)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
public fun batchSize(value: Int): Builder = apply { config = config.copy(batchSize = value) }
|
|
80
|
+
|
|
81
|
+
public fun flushIntervalMs(value: Long): Builder = apply {
|
|
82
|
+
config = config.copy(flushIntervalMs = value)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public fun signingSecret(value: String?): Builder = apply {
|
|
86
|
+
config = config.copy(signingSecret = value)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public fun onError(value: (String, Throwable) -> Unit): Builder = apply {
|
|
90
|
+
config = config.copy(onError = value)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
public fun sdkVersion(value: String?): Builder = apply {
|
|
94
|
+
config = config.copy(sdkVersion = value)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
public fun fbAppId(value: String?): Builder = apply {
|
|
98
|
+
config = config.copy(fbAppId = value)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
public fun build(): AdvenueConfig = config
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Stamped on every event as `sdkVersion`. An Android library has no runtime
|
|
107
|
+
* access to its own Gradle version without reflection, so this is a constant —
|
|
108
|
+
* and a constant drifts from the release tag unless something checks. CI does,
|
|
109
|
+
* because the first question every field report raises is which build produced
|
|
110
|
+
* the event.
|
|
111
|
+
*/
|
|
112
|
+
public object AdvenueVersion {
|
|
113
|
+
public const val CURRENT: String = "0.1.0"
|
|
114
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
package io.advenue.core
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A non-2xx ingest response. [isRetryable] separates a transient failure from a
|
|
5
|
+
* poison payload: retrying a 400 forever would block the queue head, and
|
|
6
|
+
* dropping a 429 would discard events over a throttle.
|
|
7
|
+
*/
|
|
8
|
+
internal class IngestError(public val status: Int) : Exception("advenue ingest failed: $status") {
|
|
9
|
+
public val isRetryable: Boolean
|
|
10
|
+
get() = status == 408 || status == 429 || status >= 500
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The delay armed after the Nth consecutive transient failure.
|
|
15
|
+
*
|
|
16
|
+
* Mirrors sdk-core's `armBackoff`: the cap applies to the exponential term
|
|
17
|
+
* BEFORE the jitter is added, so the true maximum is `capMs * 1.2`, not
|
|
18
|
+
* `capMs`. [random] is injected so the schedule is a pure function.
|
|
19
|
+
*/
|
|
20
|
+
internal fun backoffDelayMs(
|
|
21
|
+
failures: Int,
|
|
22
|
+
baseMs: Double,
|
|
23
|
+
capMs: Double,
|
|
24
|
+
random: Double,
|
|
25
|
+
): Double {
|
|
26
|
+
val exponential = Math.min(baseMs * Math.pow(2.0, (failures - 1).toDouble()), capMs)
|
|
27
|
+
return exponential + exponential * 0.2 * random
|
|
28
|
+
}
|