@advenue/react-native 1.0.0 → 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 -1
- package/android/src/main/kotlin/io/advenue/Advenue.kt +76 -11
- package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +36 -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 +36 -3
- package/android/src/main/kotlin/io/advenue/core/Engine.kt +96 -4
- package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +12 -0
- package/android/src/main/kotlin/io/advenue/core/Limits.kt +3 -0
- package/android/src/main/kotlin/io/advenue/core/PiiScrub.kt +77 -0
- package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +109 -33
- package/android/src/main/kotlin/io/advenue/platform/Collectors.kt +14 -0
- 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/InstallScopedStore.kt +5 -0
- package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +23 -1
- package/dist/index.cjs +16 -4
- package/dist/index.d.cts +18 -4
- package/dist/index.d.ts +18 -4
- package/dist/index.js +16 -4
- package/ios/AdvenueIosModule.swift +4 -2
- package/ios/vendor/Advenue/Advenue.swift +88 -17
- package/ios/vendor/Advenue/AdvenueConfig.swift +22 -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 +83 -9
- package/ios/vendor/AdvenueCore/EventQueue.swift +12 -0
- package/ios/vendor/AdvenueCore/SessionTracker.swift +89 -32
- package/ios/vendor/AdvenuePlatform/CacheFileStore.swift +90 -0
- package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +14 -2
- package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +14 -3
- package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +31 -0
- package/ios/vendor/AdvenuePlatform/HttpTransport.swift +28 -4
- package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +20 -4
- package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +14 -2
- package/package.json +1 -1
- package/src/index.ts +20 -3
- package/src/native-types.ts +0 -1
- package/src/types.ts +18 -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
|
|
@@ -147,6 +155,8 @@ public actor AdvenueEngine {
|
|
|
147
155
|
event.sdkVersion = config.sdkVersion
|
|
148
156
|
event.customerUserId = customerUserId
|
|
149
157
|
event.idfa = idfa
|
|
158
|
+
// B2: rıza bayrağı olaya işlenir.
|
|
159
|
+
event.limitAdTracking = limitAdTracking
|
|
150
160
|
event.vendorId = vendorId
|
|
151
161
|
event.appInstanceId = appInstanceId
|
|
152
162
|
event.consent = consentData
|
|
@@ -157,16 +167,26 @@ public actor AdvenueEngine {
|
|
|
157
167
|
}
|
|
158
168
|
event.properties = config.piiScrubEnabled ? PIIScrub.scrub(properties) : properties
|
|
159
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()
|
|
160
173
|
return true
|
|
161
174
|
}
|
|
162
175
|
|
|
163
176
|
/// Identity attached to every subsequent event. Not persisted: it is
|
|
164
177
|
/// re-resolved each launch, because ATT status and the vendor id can both
|
|
165
178
|
/// change between them.
|
|
166
|
-
|
|
179
|
+
/// B2: rıza bayrağı — her olaya işlenir, persist edilmez (kimlik gibi).
|
|
180
|
+
private var limitAdTracking: Bool?
|
|
181
|
+
|
|
182
|
+
public func setIdentity(
|
|
183
|
+
idfa: String?, vendorId: String?, appInstanceId: String?,
|
|
184
|
+
limitAdTracking: Bool? = nil
|
|
185
|
+
) {
|
|
167
186
|
self.idfa = idfa
|
|
168
187
|
self.vendorId = vendorId
|
|
169
188
|
self.appInstanceId = appInstanceId
|
|
189
|
+
self.limitAdTracking = limitAdTracking
|
|
170
190
|
}
|
|
171
191
|
|
|
172
192
|
/// The Firebase App Instance ID alone. Separate from `setIdentity` because it
|
|
@@ -177,6 +197,30 @@ public actor AdvenueEngine {
|
|
|
177
197
|
appInstanceId = id
|
|
178
198
|
}
|
|
179
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
|
+
|
|
180
224
|
/// The first-open event, at most once per installation. Returns whether it
|
|
181
225
|
/// was recorded.
|
|
182
226
|
///
|
|
@@ -193,19 +237,29 @@ public actor AdvenueEngine {
|
|
|
193
237
|
if forgotten { return false }
|
|
194
238
|
if config.requireConsent && !consent {
|
|
195
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()
|
|
196
243
|
return false
|
|
197
244
|
}
|
|
198
|
-
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()
|
|
199
251
|
|
|
200
252
|
var event = ClientEvent(
|
|
201
253
|
id: uuid.next(), deviceId: config.deviceId, type: "install", name: "install",
|
|
202
|
-
timestamp: EventEncoding.iso8601(ms:
|
|
254
|
+
timestamp: EventEncoding.iso8601(ms: installedAtMs), platform: config.platform)
|
|
203
255
|
event.installationId = config.installationId
|
|
204
256
|
event.appVersion = config.appVersion
|
|
205
257
|
event.osVersion = config.osVersion
|
|
206
258
|
event.sdkVersion = config.sdkVersion
|
|
207
259
|
event.customerUserId = customerUserId
|
|
208
260
|
event.idfa = idfa
|
|
261
|
+
// B2: rıza bayrağı install olayına da işlenir.
|
|
262
|
+
event.limitAdTracking = limitAdTracking
|
|
209
263
|
event.vendorId = vendorId
|
|
210
264
|
event.appInstanceId = appInstanceId
|
|
211
265
|
event.consent = consentData
|
|
@@ -230,7 +284,9 @@ public actor AdvenueEngine {
|
|
|
230
284
|
merged = properties
|
|
231
285
|
}
|
|
232
286
|
event.properties = config.piiScrubEnabled ? PIIScrub.scrub(merged) : merged
|
|
233
|
-
|
|
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)
|
|
234
290
|
|
|
235
291
|
store.set("1", forKey: INSTALL_SENT_KEY)
|
|
236
292
|
return true
|
|
@@ -347,6 +403,11 @@ public actor AdvenueEngine {
|
|
|
347
403
|
|
|
348
404
|
|
|
349
405
|
public func setUserId(_ id: String?) {
|
|
406
|
+
// B6: 128 üstü id tüm batch'i 400'e düşürür. Reddet, persist etme.
|
|
407
|
+
if let id, id.count > 128 {
|
|
408
|
+
onError("account.setUserId:id_too_long", IngestError(status: 400))
|
|
409
|
+
return
|
|
410
|
+
}
|
|
350
411
|
customerUserId = id
|
|
351
412
|
if let id {
|
|
352
413
|
store.set(id, forKey: USER_ID_KEY)
|
|
@@ -361,6 +422,7 @@ public actor AdvenueEngine {
|
|
|
361
422
|
/// behind, which is an erasure that does not erase (spec §5).
|
|
362
423
|
public func forgetMe() {
|
|
363
424
|
forgotten = true
|
|
425
|
+
releaseInstallHold()
|
|
364
426
|
queue.clear()
|
|
365
427
|
sessions.reset()
|
|
366
428
|
consent = false
|
|
@@ -368,7 +430,9 @@ public actor AdvenueEngine {
|
|
|
368
430
|
pushToken = nil
|
|
369
431
|
pushProvider = nil
|
|
370
432
|
customerUserId = nil
|
|
371
|
-
|
|
433
|
+
// QUEUE_KEY last: queue.clear() above persists an empty blob synchronously,
|
|
434
|
+
// and `forgotten` stops every later enqueue, so nothing rewrites it.
|
|
435
|
+
for key in [CONSENT_KEY, CONSENT_DATA_KEY, SESSION_STATE_KEY, USER_ID_KEY, QUEUE_KEY] {
|
|
372
436
|
store.removeObject(forKey: key)
|
|
373
437
|
}
|
|
374
438
|
}
|
|
@@ -403,8 +467,13 @@ public actor AdvenueEngine {
|
|
|
403
467
|
/// throws — a timer-driven call is unawaited, so a transient failure simply
|
|
404
468
|
/// leaves the batch buffered for the next attempt.
|
|
405
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() }
|
|
406
473
|
guard let transport else { return }
|
|
407
|
-
if flushing || queue.size == 0 || clock.nowMs() < backoffUntilMs
|
|
474
|
+
if flushing || queue.size == 0 || clock.nowMs() < backoffUntilMs || heldForInstall() {
|
|
475
|
+
return
|
|
476
|
+
}
|
|
408
477
|
flushing = true
|
|
409
478
|
defer { flushing = false }
|
|
410
479
|
|
|
@@ -477,7 +546,7 @@ public enum Command: Sendable {
|
|
|
477
546
|
case foreground
|
|
478
547
|
case background
|
|
479
548
|
case flush
|
|
480
|
-
case setIdentity(idfa: String?, vendorId: String?, appInstanceId: String?)
|
|
549
|
+
case setIdentity(idfa: String?, vendorId: String?, appInstanceId: String?, limitAdTracking: Bool? = nil)
|
|
481
550
|
case setAppInstanceId(String?)
|
|
482
551
|
case setConsentData(Consent?)
|
|
483
552
|
case setPushToken(token: String?, provider: String?)
|
|
@@ -488,6 +557,7 @@ public enum Command: Sendable {
|
|
|
488
557
|
reporter: any SkanReporter, configVersion: Int)
|
|
489
558
|
case trackInstall(
|
|
490
559
|
adservicesToken: String?, attestation: AttestationResult?, attestationChallenge: String?)
|
|
560
|
+
case beginInstall(holdMs: Int)
|
|
491
561
|
}
|
|
492
562
|
|
|
493
563
|
/// The ordered ingress: a synchronous, non-blocking `submit` feeding one
|
|
@@ -543,10 +613,14 @@ public final class CommandPipe: @unchecked Sendable {
|
|
|
543
613
|
await engine.notifyBackground()
|
|
544
614
|
case .flush:
|
|
545
615
|
await engine.flush()
|
|
546
|
-
case .setIdentity(let idfa, let vendorId, let appInstanceId):
|
|
547
|
-
await engine.setIdentity(
|
|
616
|
+
case .setIdentity(let idfa, let vendorId, let appInstanceId, let limitAdTracking):
|
|
617
|
+
await engine.setIdentity(
|
|
618
|
+
idfa: idfa, vendorId: vendorId, appInstanceId: appInstanceId,
|
|
619
|
+
limitAdTracking: limitAdTracking)
|
|
548
620
|
case .setAppInstanceId(let id):
|
|
549
621
|
await engine.setAppInstanceId(id)
|
|
622
|
+
case .beginInstall(let holdMs):
|
|
623
|
+
await engine.beginInstall(holdMs: holdMs)
|
|
550
624
|
case .trackInstall(let token, let attestation, let challenge):
|
|
551
625
|
await engine.trackInstall(
|
|
552
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: [
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// B4: dosya destekli anahtar-değer deposu. Caches dizini iCloud/yedek
|
|
4
|
+
/// kapsamı dışındadır — kuyruk blob'u (reklam kimlikli olay gövdeleri)
|
|
5
|
+
/// yedek imajına sızmaz. Linux taşınabilirliği için yalnızca Foundation.
|
|
6
|
+
public struct CacheFileStore: KeyValueStore, @unchecked Sendable {
|
|
7
|
+
private let directory: URL
|
|
8
|
+
private let fileManager = FileManager.default
|
|
9
|
+
|
|
10
|
+
/// - Parameter directory: Kök dizin. Üretimde Caches/advenue kullanılır;
|
|
11
|
+
/// testler geçici dizin verir.
|
|
12
|
+
public init(directory: URL) {
|
|
13
|
+
self.directory = directory
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public static func caches() -> CacheFileStore? {
|
|
17
|
+
guard
|
|
18
|
+
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
|
19
|
+
else { return nil }
|
|
20
|
+
let directory = base.appendingPathComponent("advenue", isDirectory: true)
|
|
21
|
+
try? FileManager.default.createDirectory(
|
|
22
|
+
at: directory, withIntermediateDirectories: true)
|
|
23
|
+
return CacheFileStore(directory: directory)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private func file(forKey key: String) -> URL {
|
|
27
|
+
// Anahtar dosya adı olur — boş/kök kaçışı engellenir.
|
|
28
|
+
let safe = key.replacingOccurrences(of: "/", with: "_")
|
|
29
|
+
return directory.appendingPathComponent(safe, isDirectory: false)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public func string(forKey key: String) -> String? {
|
|
33
|
+
try? String(contentsOf: file(forKey: key), encoding: .utf8)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public func set(_ value: String, forKey key: String) {
|
|
37
|
+
try? fileManager.createDirectory(
|
|
38
|
+
at: directory, withIntermediateDirectories: true)
|
|
39
|
+
try? value.write(to: file(forKey: key), atomically: true, encoding: .utf8)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public func removeObject(forKey key: String) {
|
|
43
|
+
try? fileManager.removeItem(at: file(forKey: key))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// B4: bileşik depo — kuyruk blob'u dosyalara (yedek dışı), geri kalan
|
|
48
|
+
/// UserDefaults'a. Android `CompositeStore` ile aynı yönlendirme.
|
|
49
|
+
/// Eski UserDefaults anahtarı ilk okumada migrate edilir (RN uyumluluğu).
|
|
50
|
+
public struct CompositeStore: KeyValueStore, @unchecked Sendable {
|
|
51
|
+
private let defaults: UserDefaultsStore
|
|
52
|
+
private let files: CacheFileStore
|
|
53
|
+
private let fileKeys: Set<String>
|
|
54
|
+
|
|
55
|
+
public init(
|
|
56
|
+
defaults: UserDefaultsStore = UserDefaultsStore(),
|
|
57
|
+
files: CacheFileStore,
|
|
58
|
+
fileKeys: Set<String> = [QUEUE_KEY]
|
|
59
|
+
) {
|
|
60
|
+
self.defaults = defaults
|
|
61
|
+
self.files = files
|
|
62
|
+
self.fileKeys = fileKeys
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public func string(forKey key: String) -> String? {
|
|
66
|
+
guard fileKeys.contains(key) else { return defaults.string(forKey: key) }
|
|
67
|
+
if let hit = files.string(forKey: key) { return hit }
|
|
68
|
+
// Tek seferlik geriye dönük migrate: RN/önceki sürüm UserDefaults'ta bıraktı.
|
|
69
|
+
guard let legacy = defaults.string(forKey: key) else { return nil }
|
|
70
|
+
files.set(legacy, forKey: key)
|
|
71
|
+
defaults.removeObject(forKey: key)
|
|
72
|
+
return legacy
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public func set(_ value: String, forKey key: String) {
|
|
76
|
+
if fileKeys.contains(key) {
|
|
77
|
+
files.set(value, forKey: key)
|
|
78
|
+
} else {
|
|
79
|
+
defaults.set(value, forKey: key)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public func removeObject(forKey key: String) {
|
|
84
|
+
if fileKeys.contains(key) {
|
|
85
|
+
files.removeObject(forKey: key)
|
|
86
|
+
} else {
|
|
87
|
+
defaults.removeObject(forKey: key)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -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 {
|
|
@@ -4,6 +4,37 @@ import Foundation
|
|
|
4
4
|
import UIKit
|
|
5
5
|
#endif
|
|
6
6
|
|
|
7
|
+
/// The app's own version (`CFBundleShortVersionString`) for every event's
|
|
8
|
+
/// top-level `appVersion`. Resolved by the SDK, never taken from the app: the
|
|
9
|
+
/// React Native 0.x layer filled it this way, and 1.0 relying on the config
|
|
10
|
+
/// instead left every event unversioned (2026-09-19). Nil when the bundle has
|
|
11
|
+
/// no version.
|
|
12
|
+
public func readAppVersion() -> String? {
|
|
13
|
+
guard let short = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String,
|
|
14
|
+
!short.isEmpty
|
|
15
|
+
else { return nil }
|
|
16
|
+
return short
|
|
17
|
+
}
|
|
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
|
+
|
|
7
38
|
/// Device metadata for Meta CAPI `extinfo`.
|
|
8
39
|
///
|
|
9
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
|
+
}
|