@advenue/react-native 0.9.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 -368
- 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 -675
- package/dist/index.d.cts +199 -336
- package/dist/index.d.ts +199 -336
- package/dist/index.js +182 -681
- package/ios/AdvenueIosModule.swift +144 -446
- 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 +266 -830
- package/src/native-types.ts +74 -181
- 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,596 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
#if canImport(UIKit)
|
|
4
|
+
import UIKit
|
|
5
|
+
#endif
|
|
6
|
+
|
|
7
|
+
/// The public SDK. A static facade matching the React Native SDK's names, so a
|
|
8
|
+
/// developer moving to native reads the same API, delegating to state that is
|
|
9
|
+
/// replaceable and inspectable rather than to a hidden singleton.
|
|
10
|
+
public enum Advenue {
|
|
11
|
+
private static let state = FacadeState()
|
|
12
|
+
|
|
13
|
+
/// Starts the SDK. Safe to call from `didFinishLaunchingWithOptions`, and
|
|
14
|
+
/// safe to call twice: the previous instance is shut down first. Without
|
|
15
|
+
/// that, a second call leaves two consumer tasks draining one stream and
|
|
16
|
+
/// duplicate lifecycle observers, so events are processed twice or lost.
|
|
17
|
+
public static func initialize(_ config: AdvenueConfig) {
|
|
18
|
+
state.start(config)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// Records an event. Synchronous, non-blocking and ordered.
|
|
22
|
+
public static func track(_ name: String, properties: [String: AdvenueValue]? = nil) {
|
|
23
|
+
state.submit(.track(name: name, properties: properties, type: "custom"))
|
|
24
|
+
// SKAN sees app events, never the SDK's own. An `adv_`-prefixed signal —
|
|
25
|
+
// adv_meta_aem, adv_skan_update — satisfying a conversion rule would move
|
|
26
|
+
// an advertiser's conversion value on the SDK's behalf.
|
|
27
|
+
if !name.hasPrefix("adv_") {
|
|
28
|
+
state.submit(.recordSkan(event: name, revenueMicros: nil, revenueCurrency: nil))
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// Reports revenue to SKAdNetwork, in canonical micros. A string rather than
|
|
33
|
+
/// a number because money must not go through a Double.
|
|
34
|
+
public static func recordSkanRevenue(micros: String, currency: String) {
|
|
35
|
+
state.submit(.recordSkan(event: nil, revenueMicros: micros, revenueCurrency: currency))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Reports revenue as the decimal amount a StoreKit price is quoted in
|
|
39
|
+
/// ("9.99"), converted to exact micros here. Returns false — recording
|
|
40
|
+
/// nothing — for an amount that is not a non-negative decimal with at most
|
|
41
|
+
/// six places; coercing a malformed one to zero would report a conversion
|
|
42
|
+
/// value the purchase did not earn.
|
|
43
|
+
@discardableResult
|
|
44
|
+
public static func recordSkanRevenue(amount: String, currency: String) -> Bool {
|
|
45
|
+
guard let micros = decimalToMicros(amount) else { return false }
|
|
46
|
+
recordSkanRevenue(micros: micros, currency: currency)
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public static func setUserId(_ id: String?) { state.submit(.setUserId(id)) }
|
|
51
|
+
|
|
52
|
+
public static func setConsent(_ granted: Bool) {
|
|
53
|
+
state.rememberConsent(granted)
|
|
54
|
+
state.submit(.setConsent(granted))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Whether tracking consent is currently granted. Persisted, so this is the
|
|
58
|
+
/// answer after a restart too — a caller that defaulted to false on every
|
|
59
|
+
/// cold start would silently discard a preference the user gave.
|
|
60
|
+
public static func trackingConsent() -> Bool { state.trackingConsent }
|
|
61
|
+
|
|
62
|
+
/// The granular DMA consent last set, or nil if none has been.
|
|
63
|
+
public static func consentData() -> Consent? { state.consentData }
|
|
64
|
+
|
|
65
|
+
/// The Firebase App Instance ID, when the app resolves it itself rather than
|
|
66
|
+
/// through `AdvenueFirebase`. Does not disturb the advertising identity.
|
|
67
|
+
public static func setAppInstanceId(_ id: String?) {
|
|
68
|
+
state.submit(.setAppInstanceId(id))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Granular ad-platform consent (Google DMA), forwarded by server-side
|
|
72
|
+
/// postbacks as gdpr_applies / ad_user_data / ad_personalization / ad_storage.
|
|
73
|
+
///
|
|
74
|
+
/// Leave a field nil when the user has not been asked: "not stated" is not
|
|
75
|
+
/// "denied", and inventing false on their behalf records a refusal that never
|
|
76
|
+
/// happened.
|
|
77
|
+
public static func setConsentData(_ consent: Consent?) {
|
|
78
|
+
state.rememberConsentData(consent)
|
|
79
|
+
state.submit(.setConsentData(consent))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// Registers the device's push token for uninstall measurement.
|
|
83
|
+
///
|
|
84
|
+
/// Advenue never asks for the notification permission and never displays
|
|
85
|
+
/// anything. Pass the token your push library already gives you, on every
|
|
86
|
+
/// launch: the OS can rotate it, and a stale token is what makes uninstall
|
|
87
|
+
/// measurement report churn that did not happen.
|
|
88
|
+
///
|
|
89
|
+
/// Pass `provider: "fcm"` if this app holds an FCM token rather than an APNs
|
|
90
|
+
/// one — probing an FCM token against APNs looks like an uninstall on every
|
|
91
|
+
/// device.
|
|
92
|
+
public static func setPushToken(_ token: String?, provider: String? = nil) {
|
|
93
|
+
state.submit(.setPushToken(token: token, provider: provider))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// The app the ingestion service resolved this API key to, or nil until a
|
|
97
|
+
/// batch has been accepted.
|
|
98
|
+
///
|
|
99
|
+
/// The API key is the SDK's entire app identity, so pasting the wrong one is
|
|
100
|
+
/// silent: events are still accepted, just recorded against another app, and
|
|
101
|
+
/// every screen the integrator checks is the one they believe they
|
|
102
|
+
/// configured. This is the answer to "which app am I actually writing to",
|
|
103
|
+
/// read from the device rather than inferred from the dashboard.
|
|
104
|
+
public static func resolvedAppId() -> String? { state.resolvedAppId }
|
|
105
|
+
|
|
106
|
+
/// Resolves the deferred deep link for this install, or nil for an organic
|
|
107
|
+
/// one. Safe to call once on first launch; the app routes on the result.
|
|
108
|
+
public static func resolveDeferredDeepLink() async -> DeepLink? {
|
|
109
|
+
await state.fetchDeferredDeepLink()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// Erasure. Spans both stores — see `FacadeState.forgetMe`.
|
|
113
|
+
public static func forgetMe() { state.forgetMe() }
|
|
114
|
+
|
|
115
|
+
/// The device identifier, or nil while identity is deferred.
|
|
116
|
+
///
|
|
117
|
+
/// Async because resolution can be deferred while the Keychain is locked. A
|
|
118
|
+
/// synchronous getter would have nothing to return there, and returning a
|
|
119
|
+
/// guess is exactly the bug this SDK is built to avoid.
|
|
120
|
+
public static func deviceId() async -> String? { state.currentDeviceId }
|
|
121
|
+
|
|
122
|
+
/// Forward from `application(_:open:options:)`. Callable **before**
|
|
123
|
+
/// `initialize`: a cold start from a link can run the app delegate first,
|
|
124
|
+
/// and the links that carry attribution are precisely the ones that would
|
|
125
|
+
/// be lost.
|
|
126
|
+
public static func processDeepLink(_ url: URL) { state.deepLink(url) }
|
|
127
|
+
|
|
128
|
+
/// Sends what is buffered. Safe to call at any time; a no-op when the queue
|
|
129
|
+
/// is empty or a backoff window is open.
|
|
130
|
+
public static func flush() { state.submit(.flush) }
|
|
131
|
+
|
|
132
|
+
public static func notifyForeground() { state.submit(.foreground) }
|
|
133
|
+
|
|
134
|
+
/// Backgrounding both closes the session and flushes: a batch stranded at the
|
|
135
|
+
/// moment the app leaves the foreground may not be sent for hours.
|
|
136
|
+
public static func notifyBackground() {
|
|
137
|
+
state.submit(.background)
|
|
138
|
+
state.submit(.flush)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// Presents the ATT prompt. The app decides when; the SDK never prompts on
|
|
142
|
+
/// its own.
|
|
143
|
+
@discardableResult
|
|
144
|
+
public static func requestTrackingAuthorization() async -> TrackingAuthorization {
|
|
145
|
+
await AdvertisingIdentity().requestAuthorization()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// Set by `AdvenueFirebase`; the base SDK knows only the shape, so
|
|
149
|
+
/// FirebaseAnalytics is never forced on a consumer who does not use it.
|
|
150
|
+
public static func setAppInstanceIdProvider(
|
|
151
|
+
_ provider: @escaping @Sendable () async -> String?
|
|
152
|
+
) {
|
|
153
|
+
state.setAppInstanceIdProvider(provider)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
public static func shutdown() { state.stop() }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// Thread-safe holder for the app the server reported. Diagnostics only.
|
|
160
|
+
final class AcceptedAppId: @unchecked Sendable {
|
|
161
|
+
private let lock = NSLock()
|
|
162
|
+
private var value: String?
|
|
163
|
+
|
|
164
|
+
var current: String? {
|
|
165
|
+
lock.lock()
|
|
166
|
+
defer { lock.unlock() }
|
|
167
|
+
return value
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
func set(_ appId: String) {
|
|
171
|
+
lock.lock()
|
|
172
|
+
value = appId
|
|
173
|
+
lock.unlock()
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// Holds what a static facade cannot: the live engine, the command pipe, the
|
|
178
|
+
/// pre-init deep-link buffer and the resolved identity.
|
|
179
|
+
final class FacadeState: @unchecked Sendable {
|
|
180
|
+
private let lock = NSLock()
|
|
181
|
+
private var pipe: CommandPipe?
|
|
182
|
+
private var secure: (any SecureStore)?
|
|
183
|
+
private var store: (any KeyValueStore)?
|
|
184
|
+
private var resolvedDeviceId: String?
|
|
185
|
+
/// Set by the transport after a batch is accepted; diagnostics only.
|
|
186
|
+
private let acceptedAppId = AcceptedAppId()
|
|
187
|
+
private var startedConfig: AdvenueConfig?
|
|
188
|
+
private var pendingDeepLinks: [URL] = []
|
|
189
|
+
private var seenAemUrlHashes: Set<String> = []
|
|
190
|
+
private var appInstanceIdProvider: (@Sendable () async -> String?)?
|
|
191
|
+
private var flushTimer: DispatchSourceTimer?
|
|
192
|
+
/// Observers on the app's own lifecycle notifications, and the machine that
|
|
193
|
+
/// decides what they mean. Held so `stop()` can remove them: a stale observer
|
|
194
|
+
/// submitting into a finished pipe outlives the instance that made it.
|
|
195
|
+
private var lifecycleObservers: [NSObjectProtocol] = []
|
|
196
|
+
private var foreground = ForegroundTracker()
|
|
197
|
+
/// Read caches for the two consent values. The engine owns persistence; these
|
|
198
|
+
/// exist so a synchronous getter can answer without a round trip through the
|
|
199
|
+
/// pipe, and so a caller reading back its own `setConsent` never sees the
|
|
200
|
+
/// value it just replaced.
|
|
201
|
+
private var consentMirror = false
|
|
202
|
+
private var consentDataMirror: Consent?
|
|
203
|
+
|
|
204
|
+
/// `transport` is a parameter, not a hidden construction, because an unwired
|
|
205
|
+
/// transport is otherwise invisible: every component of the send path can be
|
|
206
|
+
/// green while nothing joins them. `SeamTests` injects a recorder here.
|
|
207
|
+
func start(
|
|
208
|
+
_ config: AdvenueConfig,
|
|
209
|
+
transport: (any EventTransport)? = nil,
|
|
210
|
+
sources: EnrichmentSources? = nil,
|
|
211
|
+
skan skanReporter: (any SkanReporter)? = nil
|
|
212
|
+
) {
|
|
213
|
+
// Replace-and-shut-down, never add.
|
|
214
|
+
stop()
|
|
215
|
+
|
|
216
|
+
let store = UserDefaultsStore()
|
|
217
|
+
let secure = KeychainStore()
|
|
218
|
+
let uuid = SystemUUIDs()
|
|
219
|
+
|
|
220
|
+
let identity = resolveIdentity(secure: secure, store: store, uuid: uuid)
|
|
221
|
+
guard case .resolved(let deviceId, let installationId) = identity else {
|
|
222
|
+
// Deferred: the Keychain could not be read. Nothing is minted and
|
|
223
|
+
// nothing starts, so no event carries an invented identifier. The next
|
|
224
|
+
// launch after first unlock resolves it.
|
|
225
|
+
config.onError("identity.deferred", IngestError(status: 0))
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let osVersion: String?
|
|
230
|
+
#if canImport(UIKit)
|
|
231
|
+
osVersion = UIDevice.current.systemVersion
|
|
232
|
+
#else
|
|
233
|
+
osVersion = nil
|
|
234
|
+
#endif
|
|
235
|
+
|
|
236
|
+
let eventTransport =
|
|
237
|
+
transport
|
|
238
|
+
?? HttpTransport(
|
|
239
|
+
endpoint: config.endpoint, apiKey: config.apiKey,
|
|
240
|
+
signingSecret: config.signingSecret,
|
|
241
|
+
// Diagnostics only: it runs after the batch is already accepted, so
|
|
242
|
+
// nothing it does can turn a successful ingest into a failure.
|
|
243
|
+
onAccepted: { [acceptedAppId] appId in acceptedAppId.set(appId) })
|
|
244
|
+
|
|
245
|
+
let engine = AdvenueEngine(
|
|
246
|
+
config: EngineConfig(
|
|
247
|
+
apiKey: config.apiKey, platform: "ios", deviceId: deviceId,
|
|
248
|
+
installationId: installationId, appVersion: config.appVersion,
|
|
249
|
+
osVersion: osVersion,
|
|
250
|
+
sdkVersion: String((config.sdkVersion ?? AdvenueVersion.current).prefix(32)),
|
|
251
|
+
requireConsent: config.requireConsent, sessionWindowMs: config.sessionWindowMs,
|
|
252
|
+
batchSize: config.batchSize),
|
|
253
|
+
store: store, clock: SystemClock(), scheduler: TimerScheduler(), uuid: uuid,
|
|
254
|
+
transport: eventTransport,
|
|
255
|
+
onError: config.onError)
|
|
256
|
+
let pipe = CommandPipe(engine: engine, onError: config.onError)
|
|
257
|
+
|
|
258
|
+
// Armed only when there are rules to evaluate. The reporter is injectable
|
|
259
|
+
// for the same reason the transport is: an unwired one is invisible, and
|
|
260
|
+
// that shape has already cost this SDK three defects.
|
|
261
|
+
// SKAN is armed from the config the SERVER serves, not from one baked into
|
|
262
|
+
// the app: a conversion schema is tuned constantly and an app release cycle
|
|
263
|
+
// is weeks, so a config that can only change by shipping a binary is a
|
|
264
|
+
// config nobody changes. The app-supplied one stays as an offline default.
|
|
265
|
+
//
|
|
266
|
+
// The cached config arms SKAN synchronously through the ordered pipe, so an
|
|
267
|
+
// event tracked immediately after initialize is measured. The network fetch
|
|
268
|
+
// then re-arms if the server has something newer — a fetch raced against
|
|
269
|
+
// those first events would silently drop them.
|
|
270
|
+
let cached = SkanConfigCache.load(store)
|
|
271
|
+
let reporter = skanReporter ?? StoreKitSkanReporter(onError: config.onError)
|
|
272
|
+
|
|
273
|
+
if let initial = chooseSkanConfig(
|
|
274
|
+
fetched: nil, cached: cached, fallback: config.conversionValues),
|
|
275
|
+
let mapper = try? ConversionValueMapper(initial.rules)
|
|
276
|
+
{
|
|
277
|
+
pipe.submit(
|
|
278
|
+
.enableSkan(
|
|
279
|
+
mapper: mapper, currency: initial.rules.revenueCurrency,
|
|
280
|
+
installationId: installationId, reporter: reporter,
|
|
281
|
+
configVersion: initial.version))
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if skanReporter == nil {
|
|
285
|
+
let fetcher = HttpSkanConfigFetcher(endpoint: config.endpoint, apiKey: config.apiKey)
|
|
286
|
+
Task {
|
|
287
|
+
let fetched = try? await fetcher.fetch(etag: cached?.etag)
|
|
288
|
+
guard let chosen = chooseSkanConfig(
|
|
289
|
+
fetched: fetched, cached: cached, fallback: config.conversionValues),
|
|
290
|
+
chosen.version != cached?.version || cached == nil,
|
|
291
|
+
let mapper = try? ConversionValueMapper(chosen.rules)
|
|
292
|
+
else { return }
|
|
293
|
+
SkanConfigCache.save(chosen, to: store)
|
|
294
|
+
pipe.submit(
|
|
295
|
+
.enableSkan(
|
|
296
|
+
mapper: mapper, currency: chosen.rules.revenueCurrency,
|
|
297
|
+
installationId: installationId, reporter: reporter,
|
|
298
|
+
configVersion: chosen.version))
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
lock.lock()
|
|
303
|
+
self.store = store
|
|
304
|
+
self.secure = secure
|
|
305
|
+
// Seeded from the same persisted values the engine loads, so consent
|
|
306
|
+
// granted in a previous run is still granted after a cold start.
|
|
307
|
+
self.consentMirror = store.string(forKey: CONSENT_KEY) == "granted"
|
|
308
|
+
self.consentDataMirror = readPersistedConsentData(store)
|
|
309
|
+
self.pipe = pipe
|
|
310
|
+
self.resolvedDeviceId = deviceId
|
|
311
|
+
let buffered = pendingDeepLinks
|
|
312
|
+
pendingDeepLinks = []
|
|
313
|
+
lock.unlock()
|
|
314
|
+
|
|
315
|
+
// Replayed in arrival order, before the first session, so a deferred deep
|
|
316
|
+
// link is attributed to the launch it belongs to.
|
|
317
|
+
for url in buffered { send(url) }
|
|
318
|
+
pipe.submit(.foreground)
|
|
319
|
+
// Seeded to match: the line above IS this launch's foreground, so the
|
|
320
|
+
// activation notification that follows must not open a second session.
|
|
321
|
+
observeLifecycle(seededInForeground: true)
|
|
322
|
+
|
|
323
|
+
if config.flushIntervalMs > 0 {
|
|
324
|
+
let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility))
|
|
325
|
+
timer.schedule(
|
|
326
|
+
deadline: .now() + .milliseconds(config.flushIntervalMs),
|
|
327
|
+
repeating: .milliseconds(config.flushIntervalMs))
|
|
328
|
+
timer.setEventHandler { [weak self] in self?.submit(.flush) }
|
|
329
|
+
timer.resume()
|
|
330
|
+
lock.lock()
|
|
331
|
+
flushTimer = timer
|
|
332
|
+
lock.unlock()
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Enrichment, then the install, off the caller's thread. `initialize`
|
|
336
|
+
// returns synchronously — an SDK that blocks
|
|
337
|
+
// didFinishLaunchingWithOptions for three seconds is one nobody ships.
|
|
338
|
+
let installSources =
|
|
339
|
+
sources
|
|
340
|
+
?? EnrichmentSources.system(
|
|
341
|
+
appInstanceIdProvider: currentAppInstanceIdProvider,
|
|
342
|
+
attestation: {
|
|
343
|
+
// Two round trips, both best-effort: a device that cannot attest, a
|
|
344
|
+
// challenge the server would not issue, or an attestKey failure all
|
|
345
|
+
// yield nil and the install ships without the fields. An install held
|
|
346
|
+
// for attestation is an install lost.
|
|
347
|
+
let attestor = DeviceCheckAttestation(secure: secure)
|
|
348
|
+
let challenges = HttpChallengeFetcher(
|
|
349
|
+
endpoint: config.endpoint, apiKey: config.apiKey)
|
|
350
|
+
guard let challenge = try? await challenges.challenge(deviceId: deviceId),
|
|
351
|
+
let result = try? await attestor.attest(challenge: challenge)
|
|
352
|
+
else { return nil }
|
|
353
|
+
return (challenge: challenge, result: result)
|
|
354
|
+
})
|
|
355
|
+
Task { [weak self] in
|
|
356
|
+
let enrichment = await collectEnrichment(installSources, deadlineMs: INSTALL_WINDOW_MS)
|
|
357
|
+
self?.submit(
|
|
358
|
+
.setIdentity(
|
|
359
|
+
idfa: enrichment.idfa, vendorId: enrichment.vendorId,
|
|
360
|
+
appInstanceId: enrichment.appInstanceId))
|
|
361
|
+
self?.submit(.setDeviceInfo(collectDeviceInfo()))
|
|
362
|
+
// The CMP writes TCF to the standard defaults, and reading it is the
|
|
363
|
+
// difference between shipping a real consent signal and shipping none.
|
|
364
|
+
// Submitted before the install so the first event carries it.
|
|
365
|
+
if let consent = readTcf() { self?.submit(.setConsentData(consent)) }
|
|
366
|
+
self?.submit(
|
|
367
|
+
.trackInstall(
|
|
368
|
+
adservicesToken: enrichment.adservicesToken,
|
|
369
|
+
attestation: enrichment.attestation,
|
|
370
|
+
attestationChallenge: enrichment.attestationChallenge))
|
|
371
|
+
self?.submit(.flush)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private var currentAppInstanceIdProvider: (@Sendable () async -> String?)? {
|
|
376
|
+
lock.lock()
|
|
377
|
+
defer { lock.unlock() }
|
|
378
|
+
return appInstanceIdProvider
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
func submit(_ command: Command) {
|
|
382
|
+
lock.lock()
|
|
383
|
+
let pipe = self.pipe
|
|
384
|
+
lock.unlock()
|
|
385
|
+
pipe?.submit(command)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/// Erasure spans BOTH stores. The engine clears what lives in UserDefaults;
|
|
389
|
+
/// the durable device id lives in the Keychain and is wiped here. Calling
|
|
390
|
+
/// only the engine's `forgetMe` is the layering trap the spec recorded — an
|
|
391
|
+
/// erasure that leaves the identifier behind, and looks finished.
|
|
392
|
+
func forgetMe() {
|
|
393
|
+
submit(.forgetMe)
|
|
394
|
+
lock.lock()
|
|
395
|
+
// Erasure clears the read caches too: a getter still answering "granted"
|
|
396
|
+
// after forgetMe would report a consent the device no longer holds.
|
|
397
|
+
consentMirror = false
|
|
398
|
+
consentDataMirror = nil
|
|
399
|
+
let secure = self.secure
|
|
400
|
+
let store = self.store
|
|
401
|
+
resolvedDeviceId = nil
|
|
402
|
+
lock.unlock()
|
|
403
|
+
secure?.delete(DEVICE_ID_KEY)
|
|
404
|
+
store?.removeObject(forKey: INSTALLATION_ID_KEY)
|
|
405
|
+
store?.removeObject(forKey: INSTALL_SENT_KEY)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/// Synchronous internally: NSLock cannot be held across an async boundary,
|
|
409
|
+
/// and there is nothing to await yet. The PUBLIC accessor stays async
|
|
410
|
+
/// because deferred identity will eventually wait for first unlock, and
|
|
411
|
+
/// changing that signature later would break every caller.
|
|
412
|
+
var resolvedAppId: String? { acceptedAppId.current }
|
|
413
|
+
|
|
414
|
+
var trackingConsent: Bool {
|
|
415
|
+
lock.lock()
|
|
416
|
+
defer { lock.unlock() }
|
|
417
|
+
return consentMirror
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
var consentData: Consent? {
|
|
421
|
+
lock.lock()
|
|
422
|
+
defer { lock.unlock() }
|
|
423
|
+
return consentDataMirror
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
func rememberConsent(_ granted: Bool) {
|
|
427
|
+
lock.lock()
|
|
428
|
+
consentMirror = granted
|
|
429
|
+
lock.unlock()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
func rememberConsentData(_ consent: Consent?) {
|
|
433
|
+
lock.lock()
|
|
434
|
+
consentDataMirror = consent
|
|
435
|
+
lock.unlock()
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private func lookupTarget() -> (AdvenueConfig, String)? {
|
|
439
|
+
lock.lock()
|
|
440
|
+
defer { lock.unlock() }
|
|
441
|
+
guard let config = startedConfig, let deviceId = resolvedDeviceId else { return nil }
|
|
442
|
+
return (config, deviceId)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/// Polls the conversion lookup. Returns nil for an organic install, which is
|
|
446
|
+
/// most of them.
|
|
447
|
+
/// Named apart from the free function it calls, deliberately.
|
|
448
|
+
///
|
|
449
|
+
/// It used to share that name and reach it through an `AdvenueCore.`
|
|
450
|
+
/// qualifier. That works here and breaks in the wrapper SDKs, which flatten
|
|
451
|
+
/// these modules into one — where the qualifier names nothing and dropping it
|
|
452
|
+
/// would call this method again, forever. A distinct name removes the trap
|
|
453
|
+
/// instead of relying on everyone remembering it.
|
|
454
|
+
func fetchDeferredDeepLink() async -> DeepLink? {
|
|
455
|
+
// Snapshot synchronously first: NSLock cannot be held across an await, and
|
|
456
|
+
// the same constraint shaped `currentDeviceId`.
|
|
457
|
+
guard let (config, deviceId) = lookupTarget() else { return nil }
|
|
458
|
+
return await resolveDeferredDeepLink(
|
|
459
|
+
fetcher: HttpConversionFetcher(
|
|
460
|
+
endpoint: config.endpoint, apiKey: config.apiKey, deviceId: deviceId))
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
var currentDeviceId: String? {
|
|
464
|
+
lock.lock()
|
|
465
|
+
defer { lock.unlock() }
|
|
466
|
+
return resolvedDeviceId
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
func setAppInstanceIdProvider(_ provider: @escaping @Sendable () async -> String?) {
|
|
470
|
+
lock.lock()
|
|
471
|
+
appInstanceIdProvider = provider
|
|
472
|
+
lock.unlock()
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
func deepLink(_ url: URL) {
|
|
476
|
+
lock.lock()
|
|
477
|
+
let started = pipe != nil
|
|
478
|
+
if !started { pendingDeepLinks.append(url) }
|
|
479
|
+
lock.unlock()
|
|
480
|
+
if started { send(url) }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/// Test surface: how many links are waiting for `initialize`.
|
|
484
|
+
var bufferedDeepLinkCount: Int {
|
|
485
|
+
lock.lock()
|
|
486
|
+
defer { lock.unlock() }
|
|
487
|
+
return pendingDeepLinks.count
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private func send(_ url: URL) {
|
|
491
|
+
submit(
|
|
492
|
+
.track(
|
|
493
|
+
name: "deep_link", properties: ["url": .string(url.absoluteString)], type: "custom"))
|
|
494
|
+
|
|
495
|
+
// Meta AEM: a link from Meta carries al_applink_data with an opaque,
|
|
496
|
+
// Meta-encrypted campaign_ids blob. Emitted at most once per URL — the same
|
|
497
|
+
// link re-opened is not a second measurement, and the server dedups on this
|
|
498
|
+
// hash too.
|
|
499
|
+
guard
|
|
500
|
+
let applink = URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
|
501
|
+
.queryItems?.first(where: { $0.name == "al_applink_data" })?.value,
|
|
502
|
+
let campaignIds = extractAemCampaignIds(applink)
|
|
503
|
+
else { return }
|
|
504
|
+
|
|
505
|
+
let hash = sha256Hex(url.absoluteString)
|
|
506
|
+
lock.lock()
|
|
507
|
+
let fresh = seenAemUrlHashes.insert(hash).inserted
|
|
508
|
+
lock.unlock()
|
|
509
|
+
guard fresh else { return }
|
|
510
|
+
|
|
511
|
+
submit(
|
|
512
|
+
.track(
|
|
513
|
+
name: "adv_meta_aem",
|
|
514
|
+
properties: ["campaignIds": .string(campaignIds), "sourceUrlHash": .string(hash)],
|
|
515
|
+
type: "custom"))
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/// Subscribes to the app's own lifecycle, so an integrator does not have to.
|
|
519
|
+
///
|
|
520
|
+
/// The Android SDK has always done this through `ActivityLifecycleCallbacks`;
|
|
521
|
+
/// iOS did not, and the asymmetry had no stated reason. Its cost was real: a
|
|
522
|
+
/// native app opened one session at launch and then never another, because a
|
|
523
|
+
/// return from background after the session window went unnoticed — and
|
|
524
|
+
/// backgrounding neither closed the session nor flushed, so a batch could sit
|
|
525
|
+
/// on the device until the next launch.
|
|
526
|
+
///
|
|
527
|
+
/// `NotificationCenter` rather than `UIApplication.shared`, deliberately:
|
|
528
|
+
/// the notification names are plain constants, while `shared` is unavailable
|
|
529
|
+
/// in an app extension and merely referencing it there fails to link.
|
|
530
|
+
private func observeLifecycle(seededInForeground: Bool) {
|
|
531
|
+
// Seeded OUTSIDE the UIKit guard, deliberately. The tracker's state is
|
|
532
|
+
// platform-independent and must be right even where no observer can be
|
|
533
|
+
// registered — a macOS test process, for one, which is exactly where
|
|
534
|
+
// leaving it inside the guard made a signal read as a fresh foreground.
|
|
535
|
+
lock.lock()
|
|
536
|
+
foreground = ForegroundTracker(inForeground: seededInForeground)
|
|
537
|
+
lock.unlock()
|
|
538
|
+
|
|
539
|
+
#if canImport(UIKit)
|
|
540
|
+
let signals: [(Notification.Name, LifecycleSignal)] = [
|
|
541
|
+
(UIApplication.didBecomeActiveNotification, .didBecomeActive),
|
|
542
|
+
(UIApplication.willResignActiveNotification, .willResignActive),
|
|
543
|
+
(UIApplication.didEnterBackgroundNotification, .didEnterBackground),
|
|
544
|
+
]
|
|
545
|
+
var registered: [NSObjectProtocol] = []
|
|
546
|
+
for (name, signal) in signals {
|
|
547
|
+
registered.append(
|
|
548
|
+
NotificationCenter.default.addObserver(
|
|
549
|
+
forName: name, object: nil, queue: nil
|
|
550
|
+
) { [weak self] _ in
|
|
551
|
+
self?.handle(signal)
|
|
552
|
+
})
|
|
553
|
+
}
|
|
554
|
+
lock.lock()
|
|
555
|
+
lifecycleObservers = registered
|
|
556
|
+
lock.unlock()
|
|
557
|
+
#endif
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/// Applies one lifecycle signal. Public routing lives here rather than in the
|
|
561
|
+
/// observer closure so a test can drive it without UIKit.
|
|
562
|
+
func handle(_ signal: LifecycleSignal) {
|
|
563
|
+
lock.lock()
|
|
564
|
+
let transition = foreground.on(signal)
|
|
565
|
+
lock.unlock()
|
|
566
|
+
switch transition {
|
|
567
|
+
case .none:
|
|
568
|
+
return
|
|
569
|
+
case .enteredForeground:
|
|
570
|
+
submit(.foreground)
|
|
571
|
+
case .enteredBackground:
|
|
572
|
+
// Backgrounding both closes the session and flushes: a batch stranded at
|
|
573
|
+
// the moment the app leaves the foreground may not be sent for hours.
|
|
574
|
+
submit(.background)
|
|
575
|
+
submit(.flush)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
func stop() {
|
|
580
|
+
lock.lock()
|
|
581
|
+
let pipe = self.pipe
|
|
582
|
+
let timer = flushTimer
|
|
583
|
+
let observers = lifecycleObservers
|
|
584
|
+
self.pipe = nil
|
|
585
|
+
flushTimer = nil
|
|
586
|
+
lifecycleObservers = []
|
|
587
|
+
lock.unlock()
|
|
588
|
+
// Cancel before the pipe shuts down: a timer firing into a finished stream
|
|
589
|
+
// is harmless, but leaving it running leaks a repeating source per
|
|
590
|
+
// initialize() call. The observers go for the same reason, one instance
|
|
591
|
+
// further out.
|
|
592
|
+
timer?.cancel()
|
|
593
|
+
for observer in observers { NotificationCenter.default.removeObserver(observer) }
|
|
594
|
+
pipe?.shutdown()
|
|
595
|
+
}
|
|
596
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public struct AdvenueConfig: Sendable {
|
|
4
|
+
public var apiKey: String
|
|
5
|
+
public var endpoint: String
|
|
6
|
+
public var appVersion: String?
|
|
7
|
+
public var requireConsent: Bool
|
|
8
|
+
public var sessionWindowMs: Int64
|
|
9
|
+
/// Events per request. Matches sdk-core.
|
|
10
|
+
public var batchSize: Int
|
|
11
|
+
/// Auto-flush period. Zero disables the timer, which is what tests want and
|
|
12
|
+
/// no shipping app does.
|
|
13
|
+
public var flushIntervalMs: Int
|
|
14
|
+
|
|
15
|
+
/// Per-key HMAC secret. **Read this before setting it.** The signature
|
|
16
|
+
/// provides integrity and replay protection, not authentication: anything
|
|
17
|
+
/// shipped inside an app binary can be extracted from it, exactly as it can
|
|
18
|
+
/// from a JavaScript bundle. Leaving it nil sends unsigned requests, which
|
|
19
|
+
/// the server accepts unless the app enforces signatures.
|
|
20
|
+
public var signingSecret: String?
|
|
21
|
+
|
|
22
|
+
/// SKAdNetwork conversion values. Absent means SKAN is not armed at all —
|
|
23
|
+
/// there is nothing to report without rules, so the machine is not built
|
|
24
|
+
/// rather than built and idle.
|
|
25
|
+
public var conversionValues: ConversionValueConfig?
|
|
26
|
+
|
|
27
|
+
/// Called when the SDK swallows a best-effort failure. Never receives PII.
|
|
28
|
+
public var onError: @Sendable (String, any Error) -> Void
|
|
29
|
+
|
|
30
|
+
/// Overrides the version stamped on every event. Set by a WRAPPER SDK, never
|
|
31
|
+
/// by an app.
|
|
32
|
+
///
|
|
33
|
+
/// An event stamped with the Swift SDK's own version says the same thing for
|
|
34
|
+
/// every install and answers nothing. The useful answer is which wrapper
|
|
35
|
+
/// produced it — a React Native or Flutter release pins the native snapshot
|
|
36
|
+
/// inside it, so the wrapper's version identifies both, and wrapper-specific
|
|
37
|
+
/// bugs are the ones that need identifying. Adjust and AppsFlyer report the
|
|
38
|
+
/// wrapper for the same reason.
|
|
39
|
+
///
|
|
40
|
+
/// Capped at 32 characters by the ingest schema; a longer value would take
|
|
41
|
+
/// the whole batch down with a 400, so it is truncated rather than sent.
|
|
42
|
+
public var sdkVersion: String?
|
|
43
|
+
|
|
44
|
+
public init(
|
|
45
|
+
apiKey: String,
|
|
46
|
+
endpoint: String = DEFAULT_ENDPOINT,
|
|
47
|
+
appVersion: String? = nil,
|
|
48
|
+
requireConsent: Bool = false,
|
|
49
|
+
sessionWindowMs: Int64 = DEFAULT_SESSION_WINDOW_MS,
|
|
50
|
+
batchSize: Int = 20,
|
|
51
|
+
flushIntervalMs: Int = 15_000,
|
|
52
|
+
signingSecret: String? = nil,
|
|
53
|
+
conversionValues: ConversionValueConfig? = nil,
|
|
54
|
+
onError: @escaping @Sendable (String, any Error) -> Void = { _, _ in },
|
|
55
|
+
sdkVersion: String? = nil
|
|
56
|
+
) {
|
|
57
|
+
self.apiKey = apiKey
|
|
58
|
+
self.endpoint = endpoint
|
|
59
|
+
self.appVersion = appVersion
|
|
60
|
+
self.requireConsent = requireConsent
|
|
61
|
+
self.sessionWindowMs = sessionWindowMs
|
|
62
|
+
self.batchSize = batchSize
|
|
63
|
+
self.flushIntervalMs = flushIntervalMs
|
|
64
|
+
self.signingSecret = signingSecret
|
|
65
|
+
self.conversionValues = conversionValues
|
|
66
|
+
self.onError = onError
|
|
67
|
+
self.sdkVersion = sdkVersion
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Stamped on every event as `sdkVersion`. Swift has no runtime access to its
|
|
72
|
+
/// package version, so this is a constant — and a constant drifts from the
|
|
73
|
+
/// release tag unless something checks. CI does, because the first question
|
|
74
|
+
/// every field report raises is which build produced the event.
|
|
75
|
+
public enum AdvenueVersion {
|
|
76
|
+
public static let current = "1.0.0"
|
|
77
|
+
}
|