@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,587 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public struct EngineConfig: Sendable {
|
|
4
|
+
public var apiKey: String
|
|
5
|
+
public var platform: String
|
|
6
|
+
public var deviceId: String
|
|
7
|
+
public var installationId: String?
|
|
8
|
+
public var appVersion: String?
|
|
9
|
+
public var osVersion: String?
|
|
10
|
+
public var sdkVersion: String?
|
|
11
|
+
public var requireConsent: Bool
|
|
12
|
+
public var sessionWindowMs: Int64
|
|
13
|
+
public var maxQueueSize: Int
|
|
14
|
+
/// Events per request, and the threshold at which `track` flushes eagerly.
|
|
15
|
+
public var batchSize: Int
|
|
16
|
+
public var retryBaseMs: Double
|
|
17
|
+
public var retryCapMs: Double
|
|
18
|
+
/// M2: track properties PII scrub'u. Varsayılan-açık; yalnızca açık bayrakla
|
|
19
|
+
/// kapatılır (opt-out) ve bu dokümante risklidir.
|
|
20
|
+
public var piiScrubEnabled: Bool
|
|
21
|
+
|
|
22
|
+
public init(
|
|
23
|
+
apiKey: String, platform: String, deviceId: String, installationId: String? = nil,
|
|
24
|
+
appVersion: String? = nil, osVersion: String? = nil, sdkVersion: String? = nil,
|
|
25
|
+
requireConsent: Bool = false, sessionWindowMs: Int64 = DEFAULT_SESSION_WINDOW_MS,
|
|
26
|
+
maxQueueSize: Int = 10_000, batchSize: Int = 20,
|
|
27
|
+
retryBaseMs: Double = 1_000, retryCapMs: Double = 60_000,
|
|
28
|
+
piiScrubEnabled: Bool = true
|
|
29
|
+
) {
|
|
30
|
+
self.batchSize = batchSize
|
|
31
|
+
self.retryBaseMs = retryBaseMs
|
|
32
|
+
self.retryCapMs = retryCapMs
|
|
33
|
+
self.piiScrubEnabled = piiScrubEnabled
|
|
34
|
+
self.apiKey = apiKey
|
|
35
|
+
self.platform = platform
|
|
36
|
+
self.deviceId = deviceId
|
|
37
|
+
self.installationId = installationId
|
|
38
|
+
self.appVersion = appVersion
|
|
39
|
+
self.osVersion = osVersion
|
|
40
|
+
self.sdkVersion = sdkVersion
|
|
41
|
+
self.requireConsent = requireConsent
|
|
42
|
+
self.sessionWindowMs = sessionWindowMs
|
|
43
|
+
self.maxQueueSize = maxQueueSize
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// The server rejects a batch of more than this: `eventBatchSchema` in
|
|
48
|
+
/// `packages/shared/src/events.ts` is `z.array(clientEventSchema).min(1).max(100)`.
|
|
49
|
+
///
|
|
50
|
+
/// It lives in the core rather than beside the transport because it is a fact
|
|
51
|
+
/// about the wire, not about any one way of reaching it — and because the
|
|
52
|
+
/// engine has to clamp to it, which the transport cannot do from where it sits.
|
|
53
|
+
public let MAX_BATCH_SIZE = 100
|
|
54
|
+
|
|
55
|
+
/// Dies with the app; guards the one-per-install first-open event. Lives in the
|
|
56
|
+
/// core because the engine owns the guard — the Keychain is deliberately never
|
|
57
|
+
/// consulted for it, since a durable flag would suppress legitimate reinstalls.
|
|
58
|
+
public let INSTALL_SENT_KEY = "advenue.install_sent"
|
|
59
|
+
public let CONSENT_KEY = "advenue.consent"
|
|
60
|
+
public let CONSENT_DATA_KEY = "advenue.consent_data"
|
|
61
|
+
public let USER_ID_KEY = "advenue.user_id"
|
|
62
|
+
|
|
63
|
+
/// Owns every piece of mutable SDK state. Commands arrive through an ordered
|
|
64
|
+
/// `AsyncStream` and are handled one at a time, which reproduces sdk-core's
|
|
65
|
+
/// single-threaded semantics — the property that makes the conformance vectors
|
|
66
|
+
/// meaningful in the first place.
|
|
67
|
+
public actor AdvenueEngine {
|
|
68
|
+
private let config: EngineConfig
|
|
69
|
+
private let store: KeyValueStore
|
|
70
|
+
private let clock: Clock
|
|
71
|
+
private let uuid: UUIDSource
|
|
72
|
+
private let queue: EventQueue
|
|
73
|
+
private let sessions: SessionTracker
|
|
74
|
+
private let onError: @Sendable (String, any Error) -> Void
|
|
75
|
+
private let transport: (any EventTransport)?
|
|
76
|
+
private let onDrop: @Sendable ([ClientEvent]) -> Void
|
|
77
|
+
private let random: @Sendable () -> Double
|
|
78
|
+
|
|
79
|
+
private var consent: Bool
|
|
80
|
+
private var forgotten = false
|
|
81
|
+
private var customerUserId: String?
|
|
82
|
+
private var idfa: String?
|
|
83
|
+
private var vendorId: String?
|
|
84
|
+
private var appInstanceId: String?
|
|
85
|
+
private var consentData: Consent?
|
|
86
|
+
private var pushToken: String?
|
|
87
|
+
private var pushProvider: String?
|
|
88
|
+
private var deviceInfo: [String: AdvenueValue]?
|
|
89
|
+
/// An install refused because consent was closed, kept so granting consent
|
|
90
|
+
/// later still sends one. Without this an app using `requireConsent` that
|
|
91
|
+
/// gets consent after launch never sends an install at all — no install, no
|
|
92
|
+
/// attribution, for the life of that installation.
|
|
93
|
+
private var installDeferred: (token: String?, properties: [String: AdvenueValue]?,
|
|
94
|
+
attestation: AttestationResult?, challenge: String?)?
|
|
95
|
+
private var skan: SkanStateMachine?
|
|
96
|
+
private var skanReporter: (any SkanReporter)?
|
|
97
|
+
private var flushing = false
|
|
98
|
+
private var consecutiveFailures = 0
|
|
99
|
+
private var backoffUntilMs: Int64 = 0
|
|
100
|
+
|
|
101
|
+
public init(
|
|
102
|
+
config: EngineConfig,
|
|
103
|
+
store: KeyValueStore,
|
|
104
|
+
clock: Clock,
|
|
105
|
+
scheduler: Scheduler,
|
|
106
|
+
uuid: UUIDSource,
|
|
107
|
+
transport: (any EventTransport)? = nil,
|
|
108
|
+
onDrop: @escaping @Sendable ([ClientEvent]) -> Void = { _ in },
|
|
109
|
+
random: @escaping @Sendable () -> Double = { Double.random(in: 0..<1) },
|
|
110
|
+
onError: @escaping @Sendable (String, any Error) -> Void = { _, _ in }
|
|
111
|
+
) {
|
|
112
|
+
self.config = config
|
|
113
|
+
self.store = store
|
|
114
|
+
self.clock = clock
|
|
115
|
+
self.uuid = uuid
|
|
116
|
+
self.transport = transport
|
|
117
|
+
self.onDrop = onDrop
|
|
118
|
+
self.random = random
|
|
119
|
+
self.onError = onError
|
|
120
|
+
self.queue = EventQueue(store: store, maxSize: config.maxQueueSize, scheduler: scheduler)
|
|
121
|
+
self.sessions = SessionTracker(
|
|
122
|
+
store: store, clock: clock, windowMs: config.sessionWindowMs, uuid: uuid)
|
|
123
|
+
self.consent = store.string(forKey: CONSENT_KEY) == "granted"
|
|
124
|
+
self.consentData = readPersistedConsentData(store)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// Enqueues an event, or refuses it and says why. Returns whether it was
|
|
128
|
+
/// accepted, mirroring sdk-core's `track()`.
|
|
129
|
+
@discardableResult
|
|
130
|
+
public func track(
|
|
131
|
+
_ name: String,
|
|
132
|
+
properties: [String: AdvenueValue]? = nil,
|
|
133
|
+
type: String = "custom"
|
|
134
|
+
) -> Bool {
|
|
135
|
+
if forgotten || (config.requireConsent && !consent) { return false }
|
|
136
|
+
if let rejection = checkTrackInput(name: name, properties: properties) {
|
|
137
|
+
onError("track.rejected:\(rejection.rawValue)", IngestError(status: 400))
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
var event = ClientEvent(
|
|
142
|
+
id: uuid.next(), deviceId: config.deviceId, type: type, name: name,
|
|
143
|
+
timestamp: EventEncoding.iso8601(ms: clock.nowMs()), platform: config.platform)
|
|
144
|
+
event.installationId = config.installationId
|
|
145
|
+
event.appVersion = config.appVersion
|
|
146
|
+
event.osVersion = config.osVersion
|
|
147
|
+
event.sdkVersion = config.sdkVersion
|
|
148
|
+
event.customerUserId = customerUserId
|
|
149
|
+
event.idfa = idfa
|
|
150
|
+
event.vendorId = vendorId
|
|
151
|
+
event.appInstanceId = appInstanceId
|
|
152
|
+
event.consent = consentData
|
|
153
|
+
// Lifecycle events only — see trackInstall.
|
|
154
|
+
if type == "session" {
|
|
155
|
+
event.pushToken = pushToken
|
|
156
|
+
event.pushProvider = pushProvider
|
|
157
|
+
}
|
|
158
|
+
event.properties = config.piiScrubEnabled ? PIIScrub.scrub(properties) : properties
|
|
159
|
+
queue.enqueue(event)
|
|
160
|
+
return true
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// Identity attached to every subsequent event. Not persisted: it is
|
|
164
|
+
/// re-resolved each launch, because ATT status and the vendor id can both
|
|
165
|
+
/// change between them.
|
|
166
|
+
public func setIdentity(idfa: String?, vendorId: String?, appInstanceId: String?) {
|
|
167
|
+
self.idfa = idfa
|
|
168
|
+
self.vendorId = vendorId
|
|
169
|
+
self.appInstanceId = appInstanceId
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/// The Firebase App Instance ID alone. Separate from `setIdentity` because it
|
|
173
|
+
/// arrives on its own schedule — an app can set it at any point, and folding
|
|
174
|
+
/// it into the three-field setter would clear the advertising identity the
|
|
175
|
+
/// install enrichment resolved.
|
|
176
|
+
public func setAppInstanceId(_ id: String?) {
|
|
177
|
+
appInstanceId = id
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/// The first-open event, at most once per installation. Returns whether it
|
|
181
|
+
/// was recorded.
|
|
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
|
|
185
|
+
/// written last, so a crash between the event and the flag costs a duplicate
|
|
186
|
+
/// the backend dedup window absorbs — a permanently missing install is the
|
|
187
|
+
/// worse failure.
|
|
188
|
+
@discardableResult
|
|
189
|
+
public func trackInstall(
|
|
190
|
+
adservicesToken: String?, properties: [String: AdvenueValue]? = nil,
|
|
191
|
+
attestation: AttestationResult? = nil, attestationChallenge: String? = nil
|
|
192
|
+
) -> Bool {
|
|
193
|
+
if forgotten { return false }
|
|
194
|
+
if config.requireConsent && !consent {
|
|
195
|
+
installDeferred = (adservicesToken, properties, attestation, attestationChallenge)
|
|
196
|
+
return false
|
|
197
|
+
}
|
|
198
|
+
if store.string(forKey: INSTALL_SENT_KEY) == "1" { return false }
|
|
199
|
+
|
|
200
|
+
var event = ClientEvent(
|
|
201
|
+
id: uuid.next(), deviceId: config.deviceId, type: "install", name: "install",
|
|
202
|
+
timestamp: EventEncoding.iso8601(ms: clock.nowMs()), platform: config.platform)
|
|
203
|
+
event.installationId = config.installationId
|
|
204
|
+
event.appVersion = config.appVersion
|
|
205
|
+
event.osVersion = config.osVersion
|
|
206
|
+
event.sdkVersion = config.sdkVersion
|
|
207
|
+
event.customerUserId = customerUserId
|
|
208
|
+
event.idfa = idfa
|
|
209
|
+
event.vendorId = vendorId
|
|
210
|
+
event.appInstanceId = appInstanceId
|
|
211
|
+
event.consent = consentData
|
|
212
|
+
// Lifecycle events only. A push token is ~180 bytes and the registry needs
|
|
213
|
+
// it periodically, not on every custom event in a 100-event batch.
|
|
214
|
+
event.pushToken = pushToken
|
|
215
|
+
event.pushProvider = pushProvider
|
|
216
|
+
// Install-only: an attribution input, not a per-event property.
|
|
217
|
+
event.adservicesToken = adservicesToken
|
|
218
|
+
if let attestation {
|
|
219
|
+
event.attestationToken = attestation.attestationObject
|
|
220
|
+
event.attestationType = "app-attest"
|
|
221
|
+
event.attestationKeyId = attestation.keyId
|
|
222
|
+
event.attestationChallenge = attestationChallenge
|
|
223
|
+
}
|
|
224
|
+
// Merged rather than replacing: a caller-supplied property of the same name
|
|
225
|
+
// is the app's own and wins.
|
|
226
|
+
let merged: [String: AdvenueValue]?
|
|
227
|
+
if let deviceInfo {
|
|
228
|
+
merged = deviceInfo.merging(properties ?? [:]) { _, caller in caller }
|
|
229
|
+
} else {
|
|
230
|
+
merged = properties
|
|
231
|
+
}
|
|
232
|
+
event.properties = config.piiScrubEnabled ? PIIScrub.scrub(merged) : merged
|
|
233
|
+
queue.enqueue(event)
|
|
234
|
+
|
|
235
|
+
store.set("1", forKey: INSTALL_SENT_KEY)
|
|
236
|
+
return true
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
public func setConsent(_ granted: Bool) {
|
|
240
|
+
consent = granted
|
|
241
|
+
store.set(granted ? "granted" : "denied", forKey: CONSENT_KEY)
|
|
242
|
+
|
|
243
|
+
// Granting consent releases an install that was refused for the lack of it.
|
|
244
|
+
guard granted, let deferred = installDeferred else { return }
|
|
245
|
+
installDeferred = nil
|
|
246
|
+
trackInstall(
|
|
247
|
+
adservicesToken: deferred.token, properties: deferred.properties,
|
|
248
|
+
attestation: deferred.attestation, attestationChallenge: deferred.challenge)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/// Granular ad-platform consent. Last write wins and it is persisted, so it
|
|
252
|
+
/// survives restarts — a stated preference silently reverting on relaunch is
|
|
253
|
+
/// the failure this guards.
|
|
254
|
+
public func setConsentData(_ consent: Consent?) {
|
|
255
|
+
guard !forgotten else { return }
|
|
256
|
+
consentData = consent
|
|
257
|
+
guard let consent else {
|
|
258
|
+
store.removeObject(forKey: CONSENT_DATA_KEY)
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
if let data = try? EventEncoding.canonicalEncoder().encode(consent) {
|
|
262
|
+
store.set(String(decoding: data, as: UTF8.self), forKey: CONSENT_DATA_KEY)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
public func getConsentData() -> Consent? { consentData }
|
|
267
|
+
|
|
268
|
+
/// Device metadata attached to the install event, where Meta CAPI reads it.
|
|
269
|
+
public func setDeviceInfo(_ info: [String: AdvenueValue]) { deviceInfo = info }
|
|
270
|
+
|
|
271
|
+
/// Arms SKAN. Without a conversion-value config there is nothing to report,
|
|
272
|
+
/// so the machine is not built at all rather than built and idle.
|
|
273
|
+
///
|
|
274
|
+
/// The machine is constructed **here**, on the actor, from Sendable
|
|
275
|
+
/// ingredients. Building it outside and passing it in is what Swift 6 refuses
|
|
276
|
+
/// — it holds the store, which belongs to this actor — and the refusal is
|
|
277
|
+
/// correct rather than something to silence with @unchecked.
|
|
278
|
+
public func enableSkan(
|
|
279
|
+
mapper: ConversionValueMapper,
|
|
280
|
+
currency: String?,
|
|
281
|
+
installationId: String,
|
|
282
|
+
reporter: any SkanReporter,
|
|
283
|
+
configVersion: Int = 0
|
|
284
|
+
) {
|
|
285
|
+
skan = SkanStateMachine(
|
|
286
|
+
store: store, clock: clock, installationId: installationId,
|
|
287
|
+
mapper: mapper, currency: currency, configVersion: configVersion)
|
|
288
|
+
skanReporter = reporter
|
|
289
|
+
reporter.register()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/// Feeds SKAN and reports if the value moved.
|
|
293
|
+
///
|
|
294
|
+
/// `confirm` runs only after the reporter returns without throwing. A device
|
|
295
|
+
/// that recorded a value it never sent would refuse to send it again, and
|
|
296
|
+
/// that window would report nothing for the rest of its life.
|
|
297
|
+
public func recordSkan(
|
|
298
|
+
event: String?, revenueMicros: String? = nil, revenueCurrency: String? = nil
|
|
299
|
+
) async {
|
|
300
|
+
// Apple's conversion value is a measurement like any other, so it is gated
|
|
301
|
+
// like any other. Reporting revenue for a user who has not consented is a
|
|
302
|
+
// compliance failure, not a parity detail.
|
|
303
|
+
if forgotten || (config.requireConsent && !consent) { return }
|
|
304
|
+
guard let skan, let reporter = skanReporter else { return }
|
|
305
|
+
guard let update = skan.record(
|
|
306
|
+
event: event, revenueMicros: revenueMicros, revenueCurrency: revenueCurrency)
|
|
307
|
+
else { return }
|
|
308
|
+
|
|
309
|
+
do {
|
|
310
|
+
try await reporter.update(
|
|
311
|
+
fine: update.fineValue, coarse: update.coarseValue, lockWindow: update.lockWindow)
|
|
312
|
+
skan.confirm(update)
|
|
313
|
+
} catch {
|
|
314
|
+
skan.abandon(update)
|
|
315
|
+
onError("skan.update", error)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/// Registers the device's push token for uninstall measurement (#26).
|
|
320
|
+
///
|
|
321
|
+
/// The host app owns push registration: the SDK never asks for the
|
|
322
|
+
/// notification permission and never displays anything. Pass the token your
|
|
323
|
+
/// push library already gives you, on every launch — the OS can rotate it at
|
|
324
|
+
/// any time, and a stale token is the one thing that makes uninstall
|
|
325
|
+
/// measurement report churn that did not happen.
|
|
326
|
+
///
|
|
327
|
+
/// `provider` should be passed explicitly by an iOS app using Firebase
|
|
328
|
+
/// Messaging: that app holds an FCM token, and probing it against APNs would
|
|
329
|
+
/// look like an uninstall on every device.
|
|
330
|
+
public func setPushToken(_ token: String?, provider: String? = nil) {
|
|
331
|
+
guard !forgotten else { return }
|
|
332
|
+
guard let token else {
|
|
333
|
+
pushToken = nil
|
|
334
|
+
pushProvider = nil
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
338
|
+
guard isValidPushToken(trimmed) else {
|
|
339
|
+
pushToken = nil
|
|
340
|
+
pushProvider = nil
|
|
341
|
+
onError("push.setPushToken", IngestError(status: 400))
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
pushToken = trimmed
|
|
345
|
+
pushProvider = provider ?? (config.platform == "android" ? "fcm" : "apns")
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
public func setUserId(_ id: String?) {
|
|
350
|
+
customerUserId = id
|
|
351
|
+
if let id {
|
|
352
|
+
store.set(id, forKey: USER_ID_KEY)
|
|
353
|
+
} else {
|
|
354
|
+
store.removeObject(forKey: USER_ID_KEY)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/// Erasure. NOTE: this clears only what lives in the `KeyValueStore`. The
|
|
359
|
+
/// durable device id lives in platform secure storage and is wiped by the
|
|
360
|
+
/// platform layer — a caller that invokes only this leaves the identifier
|
|
361
|
+
/// behind, which is an erasure that does not erase (spec §5).
|
|
362
|
+
public func forgetMe() {
|
|
363
|
+
forgotten = true
|
|
364
|
+
queue.clear()
|
|
365
|
+
sessions.reset()
|
|
366
|
+
consent = false
|
|
367
|
+
consentData = nil
|
|
368
|
+
pushToken = nil
|
|
369
|
+
pushProvider = nil
|
|
370
|
+
customerUserId = nil
|
|
371
|
+
for key in [CONSENT_KEY, CONSENT_DATA_KEY, SESSION_STATE_KEY, USER_ID_KEY] {
|
|
372
|
+
store.removeObject(forKey: key)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
public func notifyForeground() {
|
|
377
|
+
let events = sessions.handleForeground()
|
|
378
|
+
for event in events {
|
|
379
|
+
track(event.name, properties: event.properties, type: "session")
|
|
380
|
+
}
|
|
381
|
+
// A conversion rule may name "session", and the RN SDK has always fed it.
|
|
382
|
+
// The generic name rather than session_start/session_end keeps one rule
|
|
383
|
+
// matching both a cold start and a return.
|
|
384
|
+
if !events.isEmpty {
|
|
385
|
+
Task { [weak self] in await self?.recordSkan(event: "session") }
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
public func notifyBackground() {
|
|
390
|
+
if let event = sessions.handleBackground() {
|
|
391
|
+
track(event.name, properties: event.properties, type: "session")
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
public func pendingEventIds() -> [String] {
|
|
396
|
+
queue.peek(Int.max).map(\.id)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/// Test surface: the ids alone cannot say what a field carries.
|
|
400
|
+
public func pendingEvents() -> [ClientEvent] { queue.peek(Int.max) }
|
|
401
|
+
|
|
402
|
+
/// Uploads buffered events. No-op when empty, re-entrancy guarded, and never
|
|
403
|
+
/// throws — a timer-driven call is unawaited, so a transient failure simply
|
|
404
|
+
/// leaves the batch buffered for the next attempt.
|
|
405
|
+
public func flush() async {
|
|
406
|
+
guard let transport else { return }
|
|
407
|
+
if flushing || queue.size == 0 || clock.nowMs() < backoffUntilMs { return }
|
|
408
|
+
flushing = true
|
|
409
|
+
defer { flushing = false }
|
|
410
|
+
|
|
411
|
+
// Clamped to the wire's limit, not trusted. The server answers 400 for a
|
|
412
|
+
// larger batch and a 400 is not retryable, so an app that set 200 would
|
|
413
|
+
// have every batch rejected and then re-sent one event at a time by the
|
|
414
|
+
// poison-isolation path: nothing lost, and every flush costing 1 + N
|
|
415
|
+
// requests forever. The constant said 100 and enforced nothing until now.
|
|
416
|
+
let events = queue.peek(min(config.batchSize, MAX_BATCH_SIZE))
|
|
417
|
+
if events.isEmpty { return }
|
|
418
|
+
|
|
419
|
+
do {
|
|
420
|
+
try await transport.send(events)
|
|
421
|
+
} catch {
|
|
422
|
+
if let ingest = error as? IngestError, !ingest.isRetryable {
|
|
423
|
+
// Poison payload. Ingest parses a batch as a whole and answers one 400
|
|
424
|
+
// for all of it, so the offender has to be found rather than the batch
|
|
425
|
+
// discarded. A 4xx is not an outage, so it does not arm the backoff.
|
|
426
|
+
onError("flush.poison", error)
|
|
427
|
+
await isolatePoison(events, transport)
|
|
428
|
+
} else {
|
|
429
|
+
// Transient (network, 5xx, 429): keep the batch and back off, so a
|
|
430
|
+
// fleet recovering from an outage does not retry in lockstep.
|
|
431
|
+
onError("flush.transport", error)
|
|
432
|
+
armBackoff()
|
|
433
|
+
}
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
queue.ack(events)
|
|
438
|
+
consecutiveFailures = 0
|
|
439
|
+
backoffUntilMs = 0
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/// Re-sends a rejected batch one event at a time so a single poison event is
|
|
443
|
+
/// dropped while the rest are delivered or kept. Stops at the first transient
|
|
444
|
+
/// error, so a network drop mid-isolation cannot turn deliverable events into
|
|
445
|
+
/// dropped ones.
|
|
446
|
+
private func isolatePoison(_ events: [ClientEvent], _ transport: any EventTransport) async {
|
|
447
|
+
for event in events {
|
|
448
|
+
do {
|
|
449
|
+
try await transport.send([event])
|
|
450
|
+
queue.ack([event])
|
|
451
|
+
} catch {
|
|
452
|
+
guard let ingest = error as? IngestError, !ingest.isRetryable else { return }
|
|
453
|
+
queue.ack([event])
|
|
454
|
+
onDrop([event])
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private func armBackoff() {
|
|
460
|
+
consecutiveFailures += 1
|
|
461
|
+
let delay = backoffDelayMs(
|
|
462
|
+
failures: consecutiveFailures, baseMs: config.retryBaseMs,
|
|
463
|
+
capMs: config.retryCapMs, random: random())
|
|
464
|
+
backoffUntilMs = clock.nowMs() + Int64(delay)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/// Test surface: the `flush/` vectors assert that a 4xx leaves this at zero.
|
|
468
|
+
public func consecutiveFailureCount() -> Int { consecutiveFailures }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/// A command submitted to the engine through the ordered ingress.
|
|
472
|
+
public enum Command: Sendable {
|
|
473
|
+
case track(name: String, properties: [String: AdvenueValue]?, type: String)
|
|
474
|
+
case setConsent(Bool)
|
|
475
|
+
case setUserId(String?)
|
|
476
|
+
case forgetMe
|
|
477
|
+
case foreground
|
|
478
|
+
case background
|
|
479
|
+
case flush
|
|
480
|
+
case setIdentity(idfa: String?, vendorId: String?, appInstanceId: String?)
|
|
481
|
+
case setAppInstanceId(String?)
|
|
482
|
+
case setConsentData(Consent?)
|
|
483
|
+
case setPushToken(token: String?, provider: String?)
|
|
484
|
+
case setDeviceInfo([String: AdvenueValue])
|
|
485
|
+
case recordSkan(event: String?, revenueMicros: String?, revenueCurrency: String?)
|
|
486
|
+
case enableSkan(
|
|
487
|
+
mapper: ConversionValueMapper, currency: String?, installationId: String,
|
|
488
|
+
reporter: any SkanReporter, configVersion: Int)
|
|
489
|
+
case trackInstall(
|
|
490
|
+
adservicesToken: String?, attestation: AttestationResult?, attestationChallenge: String?)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/// The ordered ingress: a synchronous, non-blocking `submit` feeding one
|
|
494
|
+
/// consumer task.
|
|
495
|
+
///
|
|
496
|
+
/// Why a stream and not `Task { await engine.track(...) }` per call: an
|
|
497
|
+
/// unstructured `Task` does not preserve submission order, so an event could
|
|
498
|
+
/// overtake its own `session_start`. `continuation.yield` is synchronous,
|
|
499
|
+
/// non-blocking and ordered.
|
|
500
|
+
public final class CommandPipe: @unchecked Sendable {
|
|
501
|
+
private let continuation: AsyncStream<Command>.Continuation
|
|
502
|
+
private var consumer: Task<Void, Never>?
|
|
503
|
+
|
|
504
|
+
public init(
|
|
505
|
+
engine: AdvenueEngine,
|
|
506
|
+
onError: @escaping @Sendable (String, any Error) -> Void = { _, _ in }
|
|
507
|
+
) {
|
|
508
|
+
// Unbounded on purpose: .bufferingNewest would silently DROP attribution
|
|
509
|
+
// events, and the real bound is applied downstream by the queue's cap.
|
|
510
|
+
let (stream, continuation) = AsyncStream<Command>.makeStream(
|
|
511
|
+
of: Command.self, bufferingPolicy: .unbounded)
|
|
512
|
+
self.continuation = continuation
|
|
513
|
+
self.consumer = Task {
|
|
514
|
+
for await command in stream {
|
|
515
|
+
// Each command is handled inside its own do/catch. An unhandled throw
|
|
516
|
+
// would END this task: the stream would stop draining, submit() would
|
|
517
|
+
// keep yielding into a growing buffer, nothing would send, nothing
|
|
518
|
+
// would error, and the app would not crash — attribution would simply
|
|
519
|
+
// stop. Silent total failure is the worst mode available, so this loop
|
|
520
|
+
// must be unkillable.
|
|
521
|
+
do {
|
|
522
|
+
try await Self.handle(command, engine)
|
|
523
|
+
} catch {
|
|
524
|
+
onError("engine.command", error)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private static func handle(_ command: Command, _ engine: AdvenueEngine) async throws {
|
|
531
|
+
switch command {
|
|
532
|
+
case .track(let name, let properties, let type):
|
|
533
|
+
await engine.track(name, properties: properties, type: type)
|
|
534
|
+
case .setConsent(let granted):
|
|
535
|
+
await engine.setConsent(granted)
|
|
536
|
+
case .setUserId(let id):
|
|
537
|
+
await engine.setUserId(id)
|
|
538
|
+
case .forgetMe:
|
|
539
|
+
await engine.forgetMe()
|
|
540
|
+
case .foreground:
|
|
541
|
+
await engine.notifyForeground()
|
|
542
|
+
case .background:
|
|
543
|
+
await engine.notifyBackground()
|
|
544
|
+
case .flush:
|
|
545
|
+
await engine.flush()
|
|
546
|
+
case .setIdentity(let idfa, let vendorId, let appInstanceId):
|
|
547
|
+
await engine.setIdentity(idfa: idfa, vendorId: vendorId, appInstanceId: appInstanceId)
|
|
548
|
+
case .setAppInstanceId(let id):
|
|
549
|
+
await engine.setAppInstanceId(id)
|
|
550
|
+
case .trackInstall(let token, let attestation, let challenge):
|
|
551
|
+
await engine.trackInstall(
|
|
552
|
+
adservicesToken: token, attestation: attestation, attestationChallenge: challenge)
|
|
553
|
+
case .setConsentData(let consent):
|
|
554
|
+
await engine.setConsentData(consent)
|
|
555
|
+
case .setPushToken(let token, let provider):
|
|
556
|
+
await engine.setPushToken(token, provider: provider)
|
|
557
|
+
case .setDeviceInfo(let info):
|
|
558
|
+
await engine.setDeviceInfo(info)
|
|
559
|
+
case .recordSkan(let event, let micros, let currency):
|
|
560
|
+
await engine.recordSkan(event: event, revenueMicros: micros, revenueCurrency: currency)
|
|
561
|
+
case .enableSkan(let mapper, let currency, let installationId, let reporter, let version):
|
|
562
|
+
await engine.enableSkan(
|
|
563
|
+
mapper: mapper, currency: currency, installationId: installationId,
|
|
564
|
+
reporter: reporter, configVersion: version)
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/// Synchronous, non-blocking and **ordered** — this is why the ingress is a
|
|
569
|
+
/// stream rather than a fresh Task per call.
|
|
570
|
+
public func submit(_ command: Command) {
|
|
571
|
+
continuation.yield(command)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
public func shutdown() {
|
|
575
|
+
continuation.finish()
|
|
576
|
+
consumer = nil
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/// Decodes the persisted DMA consent. Public because the platform facade
|
|
581
|
+
/// answers `consentData()` from the same bytes the engine loads — two decoders
|
|
582
|
+
/// would be two chances to disagree about what the device consented to.
|
|
583
|
+
public func readPersistedConsentData(_ store: KeyValueStore) -> Consent? {
|
|
584
|
+
guard let raw = store.string(forKey: CONSENT_DATA_KEY), let data = raw.data(using: .utf8)
|
|
585
|
+
else { return nil }
|
|
586
|
+
return try? JSONDecoder().decode(Consent.self, from: data)
|
|
587
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public let QUEUE_KEY = "advenue.queue"
|
|
4
|
+
/// Persist debounce window. Bursts inside it coalesce into one write.
|
|
5
|
+
public let PERSIST_DEBOUNCE_MS = 100
|
|
6
|
+
|
|
7
|
+
/// Durable FIFO event buffer, mirroring sdk-core's `EventQueue` including its
|
|
8
|
+
/// persisted blob shape — the RN inversion has to read what TypeScript wrote.
|
|
9
|
+
///
|
|
10
|
+
/// Persist strategy: `enqueue` debounces so a burst costs one write; `ack` and
|
|
11
|
+
/// `clear` are flush boundaries and write synchronously, because stale storage
|
|
12
|
+
/// at those points means event loss on ack or double-send after a crash.
|
|
13
|
+
///
|
|
14
|
+
/// Confined to the engine's single consumer task. Not thread-safe, and
|
|
15
|
+
/// deliberately so: the actor owns it, and a lock here would hide a mistake
|
|
16
|
+
/// rather than prevent one.
|
|
17
|
+
public final class EventQueue {
|
|
18
|
+
private var events: [ClientEvent]
|
|
19
|
+
private var persistToken: CancelToken?
|
|
20
|
+
private let store: KeyValueStore
|
|
21
|
+
private let maxSize: Int
|
|
22
|
+
private let scheduler: Scheduler
|
|
23
|
+
|
|
24
|
+
public init(store: KeyValueStore, maxSize: Int = 10_000, scheduler: Scheduler) {
|
|
25
|
+
self.store = store
|
|
26
|
+
self.maxSize = maxSize
|
|
27
|
+
self.scheduler = scheduler
|
|
28
|
+
self.events = EventQueue.load(store)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// A corrupt blob loads as empty rather than throwing: bricking the SDK on
|
|
32
|
+
/// every launch is worse than losing an offline buffer.
|
|
33
|
+
private static func load(_ store: KeyValueStore) -> [ClientEvent] {
|
|
34
|
+
guard let raw = store.string(forKey: QUEUE_KEY), let data = raw.data(using: .utf8) else {
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
return (try? JSONDecoder().decode([ClientEvent].self, from: data)) ?? []
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public var size: Int { events.count }
|
|
41
|
+
|
|
42
|
+
public func enqueue(_ event: ClientEvent) {
|
|
43
|
+
events.append(event)
|
|
44
|
+
// Bound the buffer: a long offline period on a chatty app must not grow the
|
|
45
|
+
// persisted blob without limit. Recent events are the ones worth keeping.
|
|
46
|
+
if events.count > maxSize {
|
|
47
|
+
events.removeFirst(events.count - maxSize)
|
|
48
|
+
}
|
|
49
|
+
schedulePersist()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public func peek(_ max: Int) -> [ClientEvent] {
|
|
53
|
+
Array(events.prefix(max))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public func ack(_ sent: [ClientEvent]) {
|
|
57
|
+
guard !sent.isEmpty else { return }
|
|
58
|
+
let ids = Set(sent.map(\.id))
|
|
59
|
+
events.removeAll { ids.contains($0.id) }
|
|
60
|
+
forcePersist()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
public func clear() {
|
|
64
|
+
events.removeAll()
|
|
65
|
+
forcePersist()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private func schedulePersist() {
|
|
69
|
+
guard persistToken == nil else { return }
|
|
70
|
+
persistToken = scheduler.schedule(afterMs: PERSIST_DEBOUNCE_MS) { [weak self] in
|
|
71
|
+
guard let self else { return }
|
|
72
|
+
self.persistToken = nil
|
|
73
|
+
self.write()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private func forcePersist() {
|
|
78
|
+
if let token = persistToken {
|
|
79
|
+
scheduler.cancel(token)
|
|
80
|
+
persistToken = nil
|
|
81
|
+
}
|
|
82
|
+
write()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private func write() {
|
|
86
|
+
guard let data = try? EventEncoding.canonicalEncoder().encode(events) else { return }
|
|
87
|
+
store.set(String(decoding: data, as: UTF8.self), forKey: QUEUE_KEY)
|
|
88
|
+
}
|
|
89
|
+
}
|