@advenue/react-native 1.0.1 → 1.1.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/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +4 -0
- package/android/src/main/kotlin/io/advenue/Advenue.kt +49 -9
- package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +9 -1
- package/android/src/main/kotlin/io/advenue/DebugLog.kt +51 -0
- package/android/src/main/kotlin/io/advenue/core/Backoff.kt +14 -1
- package/android/src/main/kotlin/io/advenue/core/CommandPipe.kt +28 -2
- package/android/src/main/kotlin/io/advenue/core/Engine.kt +73 -3
- package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +12 -0
- package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +109 -33
- package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +24 -10
- package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +9 -2
- package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +21 -8
- package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +7 -0
- package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +23 -1
- package/dist/index.cjs +9 -2
- package/dist/index.d.cts +13 -3
- package/dist/index.d.ts +13 -3
- package/dist/index.js +9 -2
- package/ios/AdvenueIosModule.swift +4 -1
- package/ios/vendor/Advenue/Advenue.swift +52 -17
- package/ios/vendor/Advenue/AdvenueConfig.swift +10 -2
- package/ios/vendor/Advenue/AdvenueSetupError.swift +24 -0
- package/ios/vendor/Advenue/DebugLog.swift +46 -0
- package/ios/vendor/AdvenueCore/Backoff.swift +16 -2
- package/ios/vendor/AdvenueCore/Engine.swift +58 -4
- package/ios/vendor/AdvenueCore/EventQueue.swift +12 -0
- package/ios/vendor/AdvenueCore/SessionTracker.swift +89 -32
- package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +14 -2
- package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +14 -3
- package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +19 -0
- package/ios/vendor/AdvenuePlatform/HttpTransport.swift +28 -4
- package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +4 -0
- package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +14 -2
- package/package.json +9 -9
- package/src/index.ts +11 -1
- package/src/types.ts +13 -3
|
@@ -94,6 +94,14 @@ public actor AdvenueEngine {
|
|
|
94
94
|
attestation: AttestationResult?, challenge: String?)?
|
|
95
95
|
private var skan: SkanStateMachine?
|
|
96
96
|
private var skanReporter: (any SkanReporter)?
|
|
97
|
+
/// F-SDK-3: when this launch began an install that is not recorded yet.
|
|
98
|
+
/// The install is stamped with it — the first open, not the moment the
|
|
99
|
+
/// enrichment that preceded it finished.
|
|
100
|
+
private var installStartedAtMs: Int64?
|
|
101
|
+
/// Flushes are held until the install is enqueued, so nothing reaches the
|
|
102
|
+
/// server ahead of it. Bounded: past this instant the hold lapses, because a
|
|
103
|
+
/// lost enrichment task must never strand the queue.
|
|
104
|
+
private var installHoldUntilMs: Int64?
|
|
97
105
|
private var flushing = false
|
|
98
106
|
private var consecutiveFailures = 0
|
|
99
107
|
private var backoffUntilMs: Int64 = 0
|
|
@@ -159,6 +167,9 @@ public actor AdvenueEngine {
|
|
|
159
167
|
}
|
|
160
168
|
event.properties = config.piiScrubEnabled ? PIIScrub.scrub(properties) : properties
|
|
161
169
|
queue.enqueue(event)
|
|
170
|
+
// An event is proof of life: a relaunch after an OS kill measures the gap
|
|
171
|
+
// from the last one (F-SDK-7).
|
|
172
|
+
sessions.heartbeat()
|
|
162
173
|
return true
|
|
163
174
|
}
|
|
164
175
|
|
|
@@ -186,6 +197,30 @@ public actor AdvenueEngine {
|
|
|
186
197
|
appInstanceId = id
|
|
187
198
|
}
|
|
188
199
|
|
|
200
|
+
/// Marks the start of a launch that still owes its install. Called by the
|
|
201
|
+
/// platform layer BEFORE the launch's foreground, while enrichment (ATT,
|
|
202
|
+
/// AdServices, attestation) runs. Adjust and AppsFlyer both hold every later
|
|
203
|
+
/// package behind the first one; this does the same, for at most `holdMs`.
|
|
204
|
+
public func beginInstall(holdMs: Int) {
|
|
205
|
+
guard !forgotten, store.string(forKey: INSTALL_SENT_KEY) != "1" else { return }
|
|
206
|
+
let now = clock.nowMs()
|
|
207
|
+
installStartedAtMs = now
|
|
208
|
+
installHoldUntilMs = now + Int64(holdMs)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// Whether a pending install still holds flushes back.
|
|
212
|
+
private func heldForInstall() -> Bool {
|
|
213
|
+
guard let until = installHoldUntilMs else { return false }
|
|
214
|
+
if clock.nowMs() < until { return true }
|
|
215
|
+
installHoldUntilMs = nil
|
|
216
|
+
return false
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private func releaseInstallHold() {
|
|
220
|
+
installStartedAtMs = nil
|
|
221
|
+
installHoldUntilMs = nil
|
|
222
|
+
}
|
|
223
|
+
|
|
189
224
|
/// The first-open event, at most once per installation. Returns whether it
|
|
190
225
|
/// was recorded.
|
|
191
226
|
///
|
|
@@ -202,13 +237,21 @@ public actor AdvenueEngine {
|
|
|
202
237
|
if forgotten { return false }
|
|
203
238
|
if config.requireConsent && !consent {
|
|
204
239
|
installDeferred = (adservicesToken, properties, attestation, attestationChallenge)
|
|
240
|
+
// Deferred possibly for good: the events consent does allow must not
|
|
241
|
+
// wait on it. Released on consent, the install is stamped then.
|
|
242
|
+
releaseInstallHold()
|
|
205
243
|
return false
|
|
206
244
|
}
|
|
207
|
-
if store.string(forKey: INSTALL_SENT_KEY) == "1" {
|
|
245
|
+
if store.string(forKey: INSTALL_SENT_KEY) == "1" {
|
|
246
|
+
releaseInstallHold()
|
|
247
|
+
return false
|
|
248
|
+
}
|
|
249
|
+
let installedAtMs = installStartedAtMs ?? clock.nowMs()
|
|
250
|
+
releaseInstallHold()
|
|
208
251
|
|
|
209
252
|
var event = ClientEvent(
|
|
210
253
|
id: uuid.next(), deviceId: config.deviceId, type: "install", name: "install",
|
|
211
|
-
timestamp: EventEncoding.iso8601(ms:
|
|
254
|
+
timestamp: EventEncoding.iso8601(ms: installedAtMs), platform: config.platform)
|
|
212
255
|
event.installationId = config.installationId
|
|
213
256
|
event.appVersion = config.appVersion
|
|
214
257
|
event.osVersion = config.osVersion
|
|
@@ -241,7 +284,9 @@ public actor AdvenueEngine {
|
|
|
241
284
|
merged = properties
|
|
242
285
|
}
|
|
243
286
|
event.properties = config.piiScrubEnabled ? PIIScrub.scrub(merged) : merged
|
|
244
|
-
|
|
287
|
+
// At the head: the session and custom events recorded while enrichment ran
|
|
288
|
+
// are already queued, and the install must reach the server before them.
|
|
289
|
+
queue.enqueueFirst(event)
|
|
245
290
|
|
|
246
291
|
store.set("1", forKey: INSTALL_SENT_KEY)
|
|
247
292
|
return true
|
|
@@ -377,6 +422,7 @@ public actor AdvenueEngine {
|
|
|
377
422
|
/// behind, which is an erasure that does not erase (spec §5).
|
|
378
423
|
public func forgetMe() {
|
|
379
424
|
forgotten = true
|
|
425
|
+
releaseInstallHold()
|
|
380
426
|
queue.clear()
|
|
381
427
|
sessions.reset()
|
|
382
428
|
consent = false
|
|
@@ -421,8 +467,13 @@ public actor AdvenueEngine {
|
|
|
421
467
|
/// throws — a timer-driven call is unawaited, so a transient failure simply
|
|
422
468
|
/// leaves the batch buffered for the next attempt.
|
|
423
469
|
public func flush() async {
|
|
470
|
+
// The auto-flush timer is the foreground tick: it keeps the open
|
|
471
|
+
// sub-session's last activity fresh even when the app records nothing.
|
|
472
|
+
if !forgotten { sessions.heartbeat() }
|
|
424
473
|
guard let transport else { return }
|
|
425
|
-
if flushing || queue.size == 0 || clock.nowMs() < backoffUntilMs
|
|
474
|
+
if flushing || queue.size == 0 || clock.nowMs() < backoffUntilMs || heldForInstall() {
|
|
475
|
+
return
|
|
476
|
+
}
|
|
426
477
|
flushing = true
|
|
427
478
|
defer { flushing = false }
|
|
428
479
|
|
|
@@ -506,6 +557,7 @@ public enum Command: Sendable {
|
|
|
506
557
|
reporter: any SkanReporter, configVersion: Int)
|
|
507
558
|
case trackInstall(
|
|
508
559
|
adservicesToken: String?, attestation: AttestationResult?, attestationChallenge: String?)
|
|
560
|
+
case beginInstall(holdMs: Int)
|
|
509
561
|
}
|
|
510
562
|
|
|
511
563
|
/// The ordered ingress: a synchronous, non-blocking `submit` feeding one
|
|
@@ -567,6 +619,8 @@ public final class CommandPipe: @unchecked Sendable {
|
|
|
567
619
|
limitAdTracking: limitAdTracking)
|
|
568
620
|
case .setAppInstanceId(let id):
|
|
569
621
|
await engine.setAppInstanceId(id)
|
|
622
|
+
case .beginInstall(let holdMs):
|
|
623
|
+
await engine.beginInstall(holdMs: holdMs)
|
|
570
624
|
case .trackInstall(let token, let attestation, let challenge):
|
|
571
625
|
await engine.trackInstall(
|
|
572
626
|
adservicesToken: token, attestation: attestation, attestationChallenge: challenge)
|
|
@@ -49,6 +49,18 @@ public final class EventQueue {
|
|
|
49
49
|
schedulePersist()
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/// Puts an event at the HEAD of the queue — the install, which is the first
|
|
53
|
+
/// package of an installation and must not be preceded by the session and
|
|
54
|
+
/// custom events recorded while enrichment ran. Over the cap, the oldest
|
|
55
|
+
/// events behind it are dropped, never the event just placed.
|
|
56
|
+
public func enqueueFirst(_ event: ClientEvent) {
|
|
57
|
+
events.insert(event, at: 0)
|
|
58
|
+
if events.count > maxSize {
|
|
59
|
+
events.removeSubrange(1..<(1 + events.count - maxSize))
|
|
60
|
+
}
|
|
61
|
+
schedulePersist()
|
|
62
|
+
}
|
|
63
|
+
|
|
52
64
|
public func peek(_ max: Int) -> [ClientEvent] {
|
|
53
65
|
Array(events.prefix(max))
|
|
54
66
|
}
|
|
@@ -2,6 +2,9 @@ import Foundation
|
|
|
2
2
|
|
|
3
3
|
public let SESSION_STATE_KEY = "advenue.session"
|
|
4
4
|
public let DEFAULT_SESSION_WINDOW_MS: Int64 = 1_800_000 // 30 minutes
|
|
5
|
+
/// The heartbeat persists at most this often: it runs on every recorded event,
|
|
6
|
+
/// and a chatty app must not turn that into a storage write per event.
|
|
7
|
+
public let SESSION_HEARTBEAT_MIN_INTERVAL_MS: Int64 = 1_000
|
|
5
8
|
|
|
6
9
|
/// A session lifecycle event. Values are numbers, strings and bools only.
|
|
7
10
|
public struct SessionEvent: Equatable, Sendable {
|
|
@@ -20,6 +23,10 @@ struct SessionState: Codable {
|
|
|
20
23
|
var activeStart: Int64?
|
|
21
24
|
var firstForegroundAt: Int64
|
|
22
25
|
var timeSpentMs: Int64
|
|
26
|
+
/// The last moment the open sub-session was known to be alive (F-SDK-7).
|
|
27
|
+
/// Absent in state written before it existed, which reads as "alive at
|
|
28
|
+
/// activeStart".
|
|
29
|
+
var lastActiveAt: Int64?
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
/// Platform-agnostic session state machine. Pure: it persists state and
|
|
@@ -30,6 +37,11 @@ public final class SessionTracker {
|
|
|
30
37
|
private let clock: Clock
|
|
31
38
|
private let windowMs: Int64
|
|
32
39
|
private let uuid: UUIDSource
|
|
40
|
+
/// True once THIS process opened the current sub-session (X-SDK-1). Never
|
|
41
|
+
/// persisted: a relaunched process starts false, so its tracks and flushes
|
|
42
|
+
/// before `handleForeground()` cannot keep the dead process's sub-session
|
|
43
|
+
/// alive and move its kill time to the relaunch.
|
|
44
|
+
private var ownsOpenSubSession = false
|
|
33
45
|
|
|
34
46
|
public init(store: KeyValueStore, clock: Clock, windowMs: Int64, uuid: UUIDSource) {
|
|
35
47
|
self.store = store
|
|
@@ -40,7 +52,10 @@ public final class SessionTracker {
|
|
|
40
52
|
|
|
41
53
|
/// Drops the in-memory cache so a wiped store cannot be resurrected by a
|
|
42
54
|
/// later lifecycle call (GDPR erasure).
|
|
43
|
-
public func reset() {
|
|
55
|
+
public func reset() {
|
|
56
|
+
cached = nil
|
|
57
|
+
ownsOpenSubSession = false
|
|
58
|
+
}
|
|
44
59
|
|
|
45
60
|
private func load() -> SessionState? {
|
|
46
61
|
if let cached { return cached }
|
|
@@ -58,14 +73,67 @@ public final class SessionTracker {
|
|
|
58
73
|
store.set(String(decoding: data, as: UTF8.self), forKey: SESSION_STATE_KEY)
|
|
59
74
|
}
|
|
60
75
|
|
|
61
|
-
///
|
|
62
|
-
///
|
|
63
|
-
///
|
|
64
|
-
///
|
|
65
|
-
///
|
|
76
|
+
/// Refreshes the open sub-session's last known activity. Called by the
|
|
77
|
+
/// engine on every recorded event and every flush — the auto-flush timer is
|
|
78
|
+
/// the foreground tick. It is what a relaunch after an OS kill measures the
|
|
79
|
+
/// gap from, the role Adjust's `lastActivity` plays.
|
|
80
|
+
///
|
|
81
|
+
/// A no-op until this process has run `handleForeground()`: only the process
|
|
82
|
+
/// that opened the sub-session may extend it. Otherwise an event or flush in a
|
|
83
|
+
/// relaunched process (iOS `didFinishLaunching` runs before
|
|
84
|
+
/// `didBecomeActive`; push and background wakes) would stamp the killed
|
|
85
|
+
/// sub-session alive "now" and count the dead time as active (X-SDK-1).
|
|
86
|
+
public func heartbeat() {
|
|
87
|
+
guard ownsOpenSubSession else { return }
|
|
88
|
+
guard var state = load(), let activeStart = state.activeStart else { return }
|
|
89
|
+
let now = clock.nowMs()
|
|
90
|
+
if now - (state.lastActiveAt ?? activeStart) < SESSION_HEARTBEAT_MIN_INTERVAL_MS { return }
|
|
91
|
+
state.lastActiveAt = now
|
|
92
|
+
save(state)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// Foreground transition, including cold start. Returns the events to emit,
|
|
96
|
+
/// in order:
|
|
97
|
+
///
|
|
98
|
+
/// - a synthetic `session_end` first when a sub-session was still open — the
|
|
99
|
+
/// OS killed the app before it could background. It is closed at its last
|
|
100
|
+
/// heartbeat, not at the relaunch: the time the app was dead is not active
|
|
101
|
+
/// time. A consumer must never see two sessions open, so it comes first.
|
|
102
|
+
/// - then, measured from the background (or that kill time): nothing for a
|
|
103
|
+
/// sub-session inside the window, or a `session_start` for a new session.
|
|
104
|
+
///
|
|
105
|
+
/// A kill is not a session boundary by itself (F-SDK-7). Adjust's rule — a
|
|
106
|
+
/// new session only after the session interval of inactivity — applies to a
|
|
107
|
+
/// relaunch exactly as it does to a return from the background.
|
|
66
108
|
public func handleForeground() -> [SessionEvent] {
|
|
67
|
-
let state = load()
|
|
68
109
|
let now = clock.nowMs()
|
|
110
|
+
guard var state = load(), let activeStart = state.activeStart else {
|
|
111
|
+
return resume(after: load(), now: now)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let killTime = min(now, max(activeStart, state.lastActiveAt ?? activeStart))
|
|
115
|
+
let activeMs = max(0, killTime - activeStart)
|
|
116
|
+
let timeSpentMs = state.timeSpentMs + activeMs
|
|
117
|
+
let end = SessionEvent(
|
|
118
|
+
name: "session_end",
|
|
119
|
+
properties: [
|
|
120
|
+
"sessionId": .string(state.sessionId),
|
|
121
|
+
"sessionNumber": .int(state.sessionNumber),
|
|
122
|
+
"subSession": .int(state.subSessionCount),
|
|
123
|
+
"activeMs": .int(Int(activeMs)),
|
|
124
|
+
"timeSpentMs": .int(Int(timeSpentMs)),
|
|
125
|
+
"sessionLengthMs": .int(Int(max(0, killTime - state.firstForegroundAt))),
|
|
126
|
+
"synthetic": .bool(true),
|
|
127
|
+
])
|
|
128
|
+
state.lastBackgroundAt = killTime
|
|
129
|
+
state.activeStart = nil
|
|
130
|
+
state.lastActiveAt = nil
|
|
131
|
+
state.timeSpentMs = timeSpentMs
|
|
132
|
+
return [end] + resume(after: state, now: now)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/// The foreground decision once no sub-session is open.
|
|
136
|
+
private func resume(after state: SessionState?, now: Int64) -> [SessionEvent] {
|
|
69
137
|
let gap: Int64? = state?.lastBackgroundAt.map { now - $0 }
|
|
70
138
|
|
|
71
139
|
if state == nil || gap == nil || gap! >= windowMs {
|
|
@@ -75,40 +143,27 @@ public final class SessionTracker {
|
|
|
75
143
|
SessionState(
|
|
76
144
|
sessionId: sessionId, sessionNumber: sessionNumber, lastBackgroundAt: nil,
|
|
77
145
|
subSessionCount: 1, activeStart: now, firstForegroundAt: now, timeSpentMs: 0))
|
|
146
|
+
ownsOpenSubSession = true
|
|
78
147
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
"sessionId": .string(sessionId),
|
|
83
|
-
"sessionNumber": .int(sessionNumber),
|
|
84
|
-
"subSession": .int(1),
|
|
85
|
-
"isFirstSession": .bool(sessionNumber == 1),
|
|
86
|
-
"timeSinceLastSessionMs": .int(Int(gap ?? 0)),
|
|
87
|
-
])
|
|
88
|
-
|
|
89
|
-
if let prior = state, let activeStart = prior.activeStart {
|
|
90
|
-
let killTime = prior.lastBackgroundAt ?? now
|
|
91
|
-
let activeMs = max(0, killTime - activeStart)
|
|
92
|
-
let end = SessionEvent(
|
|
93
|
-
name: "session_end",
|
|
148
|
+
return [
|
|
149
|
+
SessionEvent(
|
|
150
|
+
name: "session_start",
|
|
94
151
|
properties: [
|
|
95
|
-
"sessionId": .string(
|
|
96
|
-
"sessionNumber": .int(
|
|
97
|
-
"subSession": .int(
|
|
98
|
-
"
|
|
99
|
-
"
|
|
100
|
-
"sessionLengthMs": .int(Int(max(0, killTime - prior.firstForegroundAt))),
|
|
101
|
-
"synthetic": .bool(true),
|
|
152
|
+
"sessionId": .string(sessionId),
|
|
153
|
+
"sessionNumber": .int(sessionNumber),
|
|
154
|
+
"subSession": .int(1),
|
|
155
|
+
"isFirstSession": .bool(sessionNumber == 1),
|
|
156
|
+
"timeSinceLastSessionMs": .int(Int(gap ?? 0)),
|
|
102
157
|
])
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
return [start]
|
|
158
|
+
]
|
|
106
159
|
}
|
|
107
160
|
|
|
108
161
|
var updated = state!
|
|
109
162
|
updated.subSessionCount += 1
|
|
110
163
|
updated.activeStart = now
|
|
164
|
+
updated.lastActiveAt = nil
|
|
111
165
|
save(updated)
|
|
166
|
+
ownsOpenSubSession = true
|
|
112
167
|
return []
|
|
113
168
|
}
|
|
114
169
|
|
|
@@ -123,8 +178,10 @@ public final class SessionTracker {
|
|
|
123
178
|
let timeSpentMs = state.timeSpentMs + activeMs
|
|
124
179
|
state.lastBackgroundAt = now
|
|
125
180
|
state.activeStart = nil
|
|
181
|
+
state.lastActiveAt = nil
|
|
126
182
|
state.timeSpentMs = timeSpentMs
|
|
127
183
|
save(state)
|
|
184
|
+
ownsOpenSubSession = false
|
|
128
185
|
return SessionEvent(
|
|
129
186
|
name: "session_end",
|
|
130
187
|
properties: [
|
|
@@ -17,17 +17,29 @@ public struct HttpChallengeFetcher: ChallengeSource {
|
|
|
17
17
|
self.session = session
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
/// The exact request this fetcher would send. Public so a test can assert
|
|
21
|
+
/// the wire shape without a network round trip.
|
|
22
|
+
///
|
|
23
|
+
/// Sends `?platform=ios`: this fetcher is iOS-only by construction, and a
|
|
24
|
+
/// project-scoped key otherwise cannot resolve a listing on a normal
|
|
25
|
+
/// two-listing (iOS + Android) product — see `resolveProjectListing` in
|
|
26
|
+
/// apps/ingestion/src/app.ts, which 400s an ambiguous project key rather
|
|
27
|
+
/// than guess.
|
|
28
|
+
public func buildRequest(deviceId: String) -> URLRequest {
|
|
21
29
|
let escaped =
|
|
22
30
|
deviceId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? deviceId
|
|
23
31
|
var request = URLRequest(
|
|
24
|
-
url: URL(string: "\(endpoint)/v1/attest/challenge?deviceId=\(escaped)")!)
|
|
32
|
+
url: URL(string: "\(endpoint)/v1/attest/challenge?deviceId=\(escaped)&platform=ios")!)
|
|
25
33
|
request.httpMethod = "GET"
|
|
26
34
|
request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
|
|
27
35
|
// Matches the deadline the RN SDK uses. Attestation is enrichment: it never
|
|
28
36
|
// holds the install.
|
|
29
37
|
request.timeoutInterval = 5
|
|
38
|
+
return request
|
|
39
|
+
}
|
|
30
40
|
|
|
41
|
+
public func challenge(deviceId: String) async throws -> String {
|
|
42
|
+
let request = buildRequest(deviceId: deviceId)
|
|
31
43
|
let (payload, response) = try await session.data(for: request)
|
|
32
44
|
guard let http = response as? HTTPURLResponse,
|
|
33
45
|
(200..<300).contains(http.statusCode)
|
|
@@ -23,13 +23,24 @@ public struct HttpConversionFetcher: ConversionFetcher {
|
|
|
23
23
|
self.session = session
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
/// Sends `?platform=ios`: this fetcher is iOS-only by construction, and a
|
|
27
|
+
/// project key spans listings, so the server needs the platform to pick the
|
|
28
|
+
/// one this install was filed under (Y-LINKS-1). Without it the lookup
|
|
29
|
+
/// refused the project key the dashboard mints, and the deferred deep link
|
|
30
|
+
/// never arrived.
|
|
31
|
+
func buildRequest() -> URLRequest {
|
|
27
32
|
let escaped =
|
|
28
33
|
deviceId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? deviceId
|
|
29
|
-
var request = URLRequest(
|
|
34
|
+
var request = URLRequest(
|
|
35
|
+
url: URL(string: "\(endpoint)/sdk/conversion-data?deviceId=\(escaped)&platform=ios")!)
|
|
30
36
|
request.httpMethod = "GET"
|
|
31
37
|
request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
|
|
32
38
|
request.timeoutInterval = 15
|
|
39
|
+
return request
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public func fetch() async throws -> ConversionResult {
|
|
43
|
+
let request = buildRequest()
|
|
33
44
|
|
|
34
45
|
let payload: Data
|
|
35
46
|
let response: URLResponse
|
|
@@ -37,7 +48,7 @@ public struct HttpConversionFetcher: ConversionFetcher {
|
|
|
37
48
|
(payload, response) = try await session.data(for: request)
|
|
38
49
|
} catch {
|
|
39
50
|
// No status to reason about; 408 marks it retryable, as sdk-core does.
|
|
40
|
-
throw IngestError(status: 408)
|
|
51
|
+
throw IngestError(status: 408, networkCause: describeNetworkFailure(error))
|
|
41
52
|
}
|
|
42
53
|
guard let http = response as? HTTPURLResponse else { throw IngestError(status: 408) }
|
|
43
54
|
guard (200..<300).contains(http.statusCode) else {
|
|
@@ -16,6 +16,25 @@ public func readAppVersion() -> String? {
|
|
|
16
16
|
return short
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/// Whether this process was launched into the background — background fetch,
|
|
20
|
+
/// a silent push, a location relaunch — rather than opened by the user. Read
|
|
21
|
+
/// through the runtime, not `UIApplication.shared`: that symbol is unavailable
|
|
22
|
+
/// in an app extension and merely referencing it there fails to link. False
|
|
23
|
+
/// wherever it cannot be known (an extension, off the main thread, macOS),
|
|
24
|
+
/// which keeps the pre-existing behaviour: the launch is a foreground.
|
|
25
|
+
public func readLaunchedInBackground() -> Bool {
|
|
26
|
+
#if canImport(UIKit) && !os(watchOS)
|
|
27
|
+
guard Thread.isMainThread, !Bundle.main.bundlePath.hasSuffix(".appex") else { return false }
|
|
28
|
+
let selector = NSSelectorFromString("sharedApplication")
|
|
29
|
+
guard UIApplication.responds(to: selector),
|
|
30
|
+
let app = UIApplication.perform(selector)?.takeUnretainedValue() as? UIApplication
|
|
31
|
+
else { return false }
|
|
32
|
+
return app.applicationState == .background
|
|
33
|
+
#else
|
|
34
|
+
return false
|
|
35
|
+
#endif
|
|
36
|
+
}
|
|
37
|
+
|
|
19
38
|
/// Device metadata for Meta CAPI `extinfo`.
|
|
20
39
|
///
|
|
21
40
|
/// Best-effort per field: a field is **omitted**, never defaulted, when its
|
|
@@ -5,6 +5,10 @@ import Foundation
|
|
|
5
5
|
struct EventBatch: Encodable {
|
|
6
6
|
let apiKey: String
|
|
7
7
|
let events: [ClientEvent]
|
|
8
|
+
/// The device clock at THIS send attempt (spec 2026-10-01-data-fidelity D3).
|
|
9
|
+
/// The server compares it with its receive time to measure the clock offset,
|
|
10
|
+
/// so it is stamped on every attempt and never persisted with the queue.
|
|
11
|
+
let sentAt: String
|
|
8
12
|
}
|
|
9
13
|
|
|
10
14
|
public let DEFAULT_ENDPOINT = "https://ingest.advenue.io"
|
|
@@ -49,8 +53,12 @@ public struct HttpTransport: EventTransport, Sendable {
|
|
|
49
53
|
/// the wire shape — endpoint, headers, body, signature — without a network
|
|
50
54
|
/// round trip, which is the part that has to be right.
|
|
51
55
|
public func buildRequest(_ events: [ClientEvent]) throws -> URLRequest {
|
|
56
|
+
// One reading for both `sentAt` and the signed timestamp: they describe
|
|
57
|
+
// the same instant, and two reads could straddle a clock change.
|
|
58
|
+
let nowMs = clock.nowMs()
|
|
52
59
|
let body = try EventEncoding.canonicalEncoder()
|
|
53
|
-
.encode(
|
|
60
|
+
.encode(
|
|
61
|
+
EventBatch(apiKey: apiKey, events: events, sentAt: EventEncoding.iso8601(ms: nowMs)))
|
|
54
62
|
var request = URLRequest(url: URL(string: "\(endpoint)/v1/events")!)
|
|
55
63
|
request.httpMethod = "POST"
|
|
56
64
|
request.setValue("application/json", forHTTPHeaderField: "content-type")
|
|
@@ -58,7 +66,7 @@ public struct HttpTransport: EventTransport, Sendable {
|
|
|
58
66
|
request.timeoutInterval = 15
|
|
59
67
|
|
|
60
68
|
if let secret = signingSecret {
|
|
61
|
-
let timestamp = String(
|
|
69
|
+
let timestamp = String(nowMs)
|
|
62
70
|
request.setValue(timestamp, forHTTPHeaderField: "X-Advenue-Timestamp")
|
|
63
71
|
request.setValue(
|
|
64
72
|
signRequest(
|
|
@@ -77,8 +85,8 @@ public struct HttpTransport: EventTransport, Sendable {
|
|
|
77
85
|
(payload, response) = try await session.data(for: request)
|
|
78
86
|
} catch {
|
|
79
87
|
// No status to reason about. 408 marks it retryable, which is what
|
|
80
|
-
// sdk-core does for the same case.
|
|
81
|
-
throw IngestError(status: 408)
|
|
88
|
+
// sdk-core does for the same case; the cause says it was the network.
|
|
89
|
+
throw IngestError(status: 408, networkCause: describeNetworkFailure(error))
|
|
82
90
|
}
|
|
83
91
|
guard let http = response as? HTTPURLResponse else { throw IngestError(status: 408) }
|
|
84
92
|
guard (200..<300).contains(http.statusCode) else {
|
|
@@ -94,3 +102,19 @@ public struct HttpTransport: EventTransport, Sendable {
|
|
|
94
102
|
}
|
|
95
103
|
}
|
|
96
104
|
}
|
|
105
|
+
|
|
106
|
+
/// A short, identifier-free name for a failure that produced no response.
|
|
107
|
+
func describeNetworkFailure(_ error: any Error) -> String {
|
|
108
|
+
guard let urlError = error as? URLError else { return String(describing: type(of: error)) }
|
|
109
|
+
switch urlError.code {
|
|
110
|
+
case .notConnectedToInternet: return "not connected to the internet"
|
|
111
|
+
case .timedOut: return "timed out"
|
|
112
|
+
case .cannotFindHost, .dnsLookupFailed: return "host not found"
|
|
113
|
+
case .cannotConnectToHost: return "connection refused"
|
|
114
|
+
case .networkConnectionLost: return "connection lost"
|
|
115
|
+
case .secureConnectionFailed, .serverCertificateUntrusted, .serverCertificateHasBadDate,
|
|
116
|
+
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
|
|
117
|
+
return "TLS failure"
|
|
118
|
+
default: return "URLError \(urlError.code.rawValue)"
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -4,6 +4,10 @@ import Foundation
|
|
|
4
4
|
/// Spec §5: the install waits this long for enrichment and no longer. Matches
|
|
5
5
|
/// the deadlines the RN SDK already uses (SKAN config 3000 ms).
|
|
6
6
|
public let INSTALL_WINDOW_MS = 3_000
|
|
7
|
+
/// How long past `INSTALL_WINDOW_MS` flushes stay held for a pending install —
|
|
8
|
+
/// the enrichment deadline is checked by a task that still has to be
|
|
9
|
+
/// scheduled and then hop through the command pipe.
|
|
10
|
+
public let INSTALL_HOLD_MARGIN_MS = 2_000
|
|
7
11
|
|
|
8
12
|
/// What the install event carries beyond the envelope every event has.
|
|
9
13
|
public struct Enrichment: Sendable, Equatable {
|
|
@@ -22,12 +22,24 @@ public struct HttpSkanConfigFetcher: SkanConfigSource {
|
|
|
22
22
|
self.session = session
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
/// The exact request this fetcher would send. Public so a test can assert
|
|
26
|
+
/// the wire shape without a network round trip.
|
|
27
|
+
///
|
|
28
|
+
/// Sends `?platform=ios`: this fetcher is iOS-only by construction, and a
|
|
29
|
+
/// project-scoped key otherwise cannot resolve a listing on a normal
|
|
30
|
+
/// two-listing (iOS + Android) product — see `resolveProjectListing` in
|
|
31
|
+
/// apps/ingestion/src/app.ts, which 400s an ambiguous project key rather
|
|
32
|
+
/// than guess.
|
|
33
|
+
public func buildRequest(etag: String?) -> URLRequest {
|
|
34
|
+
var request = URLRequest(url: URL(string: "\(endpoint)/v1/sdk-config?platform=ios")!)
|
|
27
35
|
request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
|
|
28
36
|
if let etag { request.setValue(etag, forHTTPHeaderField: "if-none-match") }
|
|
29
37
|
request.timeoutInterval = Double(SKAN_CONFIG_TIMEOUT_MS) / 1000
|
|
38
|
+
return request
|
|
39
|
+
}
|
|
30
40
|
|
|
41
|
+
public func fetch(etag: String?) async throws -> SkanConfigResult {
|
|
42
|
+
let request = buildRequest(etag: etag)
|
|
31
43
|
let (payload, response) = try await session.data(for: request)
|
|
32
44
|
guard let http = response as? HTTPURLResponse else { throw IngestError(status: 408) }
|
|
33
45
|
if http.statusCode == 304 { return .notModified }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@advenue/react-native",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Advenue MMP React Native SDK — attribution, install tracking, and deep links (bundled iOS/Android Expo native modules)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,12 +30,6 @@
|
|
|
30
30
|
"publishConfig": {
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
|
-
"scripts": {
|
|
34
|
-
"build": "node scripts/vendor-natives.mjs && node scripts/check-vendored-swift.mjs && tsup && node scripts/check-dist.mjs",
|
|
35
|
-
"typecheck": "tsc --noEmit",
|
|
36
|
-
"test": "vitest run --minWorkers=1 --maxWorkers=2 && node --test scripts/vendor-natives.test.mjs && node scripts/vendor-natives.mjs && node scripts/check-vendored-swift.mjs",
|
|
37
|
-
"vendor": "node scripts/vendor-natives.mjs"
|
|
38
|
-
},
|
|
39
33
|
"dependencies": {},
|
|
40
34
|
"peerDependencies": {
|
|
41
35
|
"@expo/config-plugins": ">=8",
|
|
@@ -56,5 +50,11 @@
|
|
|
56
50
|
"typescript": "^5.7.2",
|
|
57
51
|
"vitest": "^2.1.8"
|
|
58
52
|
},
|
|
59
|
-
"react-native": "./dist/index.cjs"
|
|
60
|
-
|
|
53
|
+
"react-native": "./dist/index.cjs",
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "node scripts/vendor-natives.mjs && node scripts/check-vendored-swift.mjs && tsup && node scripts/check-dist.mjs",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"test": "vitest run --minWorkers=1 --maxWorkers=2 && node --test scripts/vendor-natives.test.mjs && node scripts/vendor-natives.mjs && node scripts/check-vendored-swift.mjs",
|
|
58
|
+
"vendor": "node scripts/vendor-natives.mjs"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -63,6 +63,8 @@ let started = false;
|
|
|
63
63
|
let deepLinkSub: DeepLinkHandle | null = null;
|
|
64
64
|
let errorSub: { remove(): void } | null = null;
|
|
65
65
|
let onErrorHook: ((context: string, cause: unknown) => void) | undefined;
|
|
66
|
+
/** `config.debug`: failures are also written to the JS console. */
|
|
67
|
+
let debugLogging = false;
|
|
66
68
|
|
|
67
69
|
/**
|
|
68
70
|
* `getTrackingConsent` is synchronous in JavaScript and the value lives in the
|
|
@@ -105,6 +107,9 @@ function report(context: string, cause: unknown): void {
|
|
|
105
107
|
} catch {
|
|
106
108
|
// A throwing diagnostics hook must never break the SDK.
|
|
107
109
|
}
|
|
110
|
+
// Native failures come back through `onAdvenueError`, so this covers both
|
|
111
|
+
// sides. The native SDKs log their start and sent batches themselves.
|
|
112
|
+
if (debugLogging) console.warn(`[Advenue] ${context}`, cause);
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
function requireStarted(): boolean {
|
|
@@ -122,6 +127,7 @@ export const Advenue = {
|
|
|
122
127
|
*/
|
|
123
128
|
async initialize(config: RNAdvenueConfig): Promise<void> {
|
|
124
129
|
onErrorHook = config.onError;
|
|
130
|
+
debugLogging = config.debug === true;
|
|
125
131
|
|
|
126
132
|
for (const option of IGNORED_OPTIONS) {
|
|
127
133
|
if ((config as unknown as Record<string, unknown>)[option] !== undefined) {
|
|
@@ -145,7 +151,11 @@ export const Advenue = {
|
|
|
145
151
|
signingSecret: config.signingSecret,
|
|
146
152
|
fbAppId: config.fbAppId,
|
|
147
153
|
autoTrackInstall: config.autoTrackInstall !== false,
|
|
148
|
-
|
|
154
|
+
// Names the wrapper, like Flutter's `advenue_sdk/…`: a bare version reads
|
|
155
|
+
// like the native SDK's own stamp.
|
|
156
|
+
sdkVersion: `react-native/${pkg.version}`,
|
|
157
|
+
debug: config.debug === true,
|
|
158
|
+
allowInsecureHttp: config.allowInsecureHttp === true,
|
|
149
159
|
});
|
|
150
160
|
started = true;
|
|
151
161
|
|
package/src/types.ts
CHANGED
|
@@ -59,14 +59,12 @@ export interface TrackOptions {
|
|
|
59
59
|
export interface AdvenueConfig {
|
|
60
60
|
apiKey: string;
|
|
61
61
|
endpoint?: string;
|
|
62
|
-
platform?: 'ios' | 'android' | 'web' | string;
|
|
63
62
|
/**
|
|
64
63
|
* @deprecated Ignored: the native SDK resolves the app version from the
|
|
65
64
|
* package/bundle. Passing it reports `config.ignored:appVersion` via
|
|
66
65
|
* `onError`. Removed in the next major.
|
|
67
66
|
*/
|
|
68
67
|
appVersion?: string;
|
|
69
|
-
osVersion?: string;
|
|
70
68
|
/** Gates every event on `setTrackingConsent(true)`. */
|
|
71
69
|
requireConsent?: boolean;
|
|
72
70
|
/** Events per request. */
|
|
@@ -79,8 +77,20 @@ export interface AdvenueConfig {
|
|
|
79
77
|
* extracted from it.
|
|
80
78
|
*/
|
|
81
79
|
signingSecret?: string;
|
|
82
|
-
|
|
80
|
+
/**
|
|
81
|
+
* **Test only.** Allows an `http://` endpoint — a local self-hosted ingest,
|
|
82
|
+
* or a test server — which the native SDKs otherwise refuse at initialize.
|
|
83
|
+
* Never set it in a production build: everything, the API key included,
|
|
84
|
+
* then travels unencrypted.
|
|
85
|
+
*/
|
|
86
|
+
allowInsecureHttp?: boolean;
|
|
83
87
|
/** Called when the SDK swallows a best-effort failure. Never receives PII. */
|
|
84
88
|
onError?: (context: string, cause: unknown) => void;
|
|
89
|
+
/**
|
|
90
|
+
* Development aid: the native SDKs log their start, every swallowed failure
|
|
91
|
+
* and each sent batch to Xcode's console / logcat, and every failure is also
|
|
92
|
+
* written to the JS console as `[Advenue] <context>`. Never logs an
|
|
93
|
+
* identifier or a payload. Leave off in production; use `onError`.
|
|
94
|
+
*/
|
|
85
95
|
debug?: boolean;
|
|
86
96
|
}
|