@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.
Files changed (42) hide show
  1. package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +4 -1
  2. package/android/src/main/kotlin/io/advenue/Advenue.kt +76 -11
  3. package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +36 -1
  4. package/android/src/main/kotlin/io/advenue/DebugLog.kt +51 -0
  5. package/android/src/main/kotlin/io/advenue/core/Backoff.kt +14 -1
  6. package/android/src/main/kotlin/io/advenue/core/CommandPipe.kt +36 -3
  7. package/android/src/main/kotlin/io/advenue/core/Engine.kt +96 -4
  8. package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +12 -0
  9. package/android/src/main/kotlin/io/advenue/core/Limits.kt +3 -0
  10. package/android/src/main/kotlin/io/advenue/core/PiiScrub.kt +77 -0
  11. package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +109 -33
  12. package/android/src/main/kotlin/io/advenue/platform/Collectors.kt +14 -0
  13. package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +24 -10
  14. package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +9 -2
  15. package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +21 -8
  16. package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +7 -0
  17. package/android/src/main/kotlin/io/advenue/platform/InstallScopedStore.kt +5 -0
  18. package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +23 -1
  19. package/dist/index.cjs +16 -4
  20. package/dist/index.d.cts +18 -4
  21. package/dist/index.d.ts +18 -4
  22. package/dist/index.js +16 -4
  23. package/ios/AdvenueIosModule.swift +4 -2
  24. package/ios/vendor/Advenue/Advenue.swift +88 -17
  25. package/ios/vendor/Advenue/AdvenueConfig.swift +22 -2
  26. package/ios/vendor/Advenue/AdvenueSetupError.swift +24 -0
  27. package/ios/vendor/Advenue/DebugLog.swift +46 -0
  28. package/ios/vendor/AdvenueCore/Backoff.swift +16 -2
  29. package/ios/vendor/AdvenueCore/Engine.swift +83 -9
  30. package/ios/vendor/AdvenueCore/EventQueue.swift +12 -0
  31. package/ios/vendor/AdvenueCore/SessionTracker.swift +89 -32
  32. package/ios/vendor/AdvenuePlatform/CacheFileStore.swift +90 -0
  33. package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +14 -2
  34. package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +14 -3
  35. package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +31 -0
  36. package/ios/vendor/AdvenuePlatform/HttpTransport.swift +28 -4
  37. package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +20 -4
  38. package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +14 -2
  39. package/package.json +1 -1
  40. package/src/index.ts +20 -3
  41. package/src/native-types.ts +0 -1
  42. 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
- public func setIdentity(idfa: String?, vendorId: String?, appInstanceId: String?) {
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" { return false }
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: clock.nowMs()), platform: config.platform)
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
- queue.enqueue(event)
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
- for key in [CONSENT_KEY, CONSENT_DATA_KEY, SESSION_STATE_KEY, USER_ID_KEY] {
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 { return }
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(idfa: idfa, vendorId: vendorId, appInstanceId: appInstanceId)
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() { cached = nil }
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
- /// Foreground transition, including cold start. Returns the events to emit:
62
- /// none for a sub-session, one `session_start` for a new session, or a
63
- /// synthetic `session_end` FOLLOWED BY the start when the previous session
64
- /// was still open — the OS killed the app before it could background. The
65
- /// order is load-bearing: a consumer must never see two sessions open.
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
- let start = SessionEvent(
80
- name: "session_start",
81
- properties: [
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(prior.sessionId),
96
- "sessionNumber": .int(prior.sessionNumber),
97
- "subSession": .int(prior.subSessionCount),
98
- "activeMs": .int(Int(activeMs)),
99
- "timeSpentMs": .int(Int(prior.timeSpentMs + activeMs)),
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
- return [end, start]
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
- public func challenge(deviceId: String) async throws -> String {
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
- public func fetch() async throws -> ConversionResult {
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(url: URL(string: "\(endpoint)/sdk/conversion-data?deviceId=\(escaped)")!)
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(EventBatch(apiKey: apiKey, events: events))
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(clock.nowMs())
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
+ }