@advenue/react-native 1.0.1 → 1.1.1

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 (37) hide show
  1. package/android/build.gradle +8 -2
  2. package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +4 -0
  3. package/android/src/main/kotlin/io/advenue/Advenue.kt +49 -9
  4. package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +9 -1
  5. package/android/src/main/kotlin/io/advenue/DebugLog.kt +51 -0
  6. package/android/src/main/kotlin/io/advenue/core/Backoff.kt +14 -1
  7. package/android/src/main/kotlin/io/advenue/core/CommandPipe.kt +28 -2
  8. package/android/src/main/kotlin/io/advenue/core/Engine.kt +73 -3
  9. package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +12 -0
  10. package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +109 -33
  11. package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +24 -10
  12. package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +9 -2
  13. package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +21 -8
  14. package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +7 -0
  15. package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +23 -1
  16. package/dist/index.cjs +18 -2
  17. package/dist/index.d.cts +13 -3
  18. package/dist/index.d.ts +13 -3
  19. package/dist/index.js +18 -2
  20. package/ios/AdvenueIosModule.swift +4 -1
  21. package/ios/vendor/Advenue/Advenue.swift +53 -18
  22. package/ios/vendor/Advenue/AdvenueConfig.swift +17 -3
  23. package/ios/vendor/Advenue/AdvenueSetupError.swift +24 -0
  24. package/ios/vendor/Advenue/DebugLog.swift +46 -0
  25. package/ios/vendor/AdvenueCore/Backoff.swift +16 -2
  26. package/ios/vendor/AdvenueCore/Engine.swift +58 -4
  27. package/ios/vendor/AdvenueCore/EventQueue.swift +12 -0
  28. package/ios/vendor/AdvenueCore/SessionTracker.swift +89 -32
  29. package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +14 -2
  30. package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +14 -3
  31. package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +19 -0
  32. package/ios/vendor/AdvenuePlatform/HttpTransport.swift +28 -4
  33. package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +4 -0
  34. package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +14 -2
  35. package/package.json +18 -9
  36. package/src/index.ts +11 -1
  37. package/src/types.ts +13 -3
@@ -129,14 +129,11 @@ public enum Advenue {
129
129
  /// is empty or a backoff window is open.
130
130
  public static func flush() { state.submit(.flush) }
131
131
 
132
- public static func notifyForeground() { state.submit(.foreground) }
132
+ public static func notifyForeground() { state.notifyForeground() }
133
133
 
134
134
  /// Backgrounding both closes the session and flushes: a batch stranded at the
135
135
  /// moment the app leaves the foreground may not be sent for hours.
136
- public static func notifyBackground() {
137
- state.submit(.background)
138
- state.submit(.flush)
139
- }
136
+ public static func notifyBackground() { state.notifyBackground() }
140
137
 
141
138
  /// Presents the ATT prompt. The app decides when; the SDK never prompts on
142
139
  /// its own.
@@ -167,10 +164,14 @@ final class AcceptedAppId: @unchecked Sendable {
167
164
  return value
168
165
  }
169
166
 
170
- func set(_ appId: String) {
167
+ /// True the first time this app id is seen.
168
+ @discardableResult
169
+ func set(_ appId: String) -> Bool {
171
170
  lock.lock()
171
+ defer { lock.unlock() }
172
+ let changed = value != appId
172
173
  value = appId
173
- lock.unlock()
174
+ return changed
174
175
  }
175
176
  }
176
177
 
@@ -209,17 +210,21 @@ final class FacadeState: @unchecked Sendable {
209
210
  transport: (any EventTransport)? = nil,
210
211
  sources: EnrichmentSources? = nil,
211
212
  skan skanReporter: (any SkanReporter)? = nil,
212
- appVersion readVersion: () -> String? = readAppVersion
213
+ appVersion readVersion: () -> String? = readAppVersion,
214
+ launchedInBackground: () -> Bool = readLaunchedInBackground,
215
+ logSink: (any AdvenueLogSink)? = nil
213
216
  ) {
214
217
  // Replace-and-shut-down, never add.
215
218
  stop()
216
219
 
220
+ // `debug`: every swallowed failure goes through onError, so logging there
221
+ // covers all of them — wrapped once, before anything below captures it.
222
+ let (config, log) = withDebugLogging(config, sink: logSink)
223
+
217
224
  // The SDK resolves the app version itself (readAppVersion); a config value
218
225
  // is ignored — and said so, rather than looking like it took effect.
219
226
  if config.appVersion != nil {
220
- config.onError(
221
- "config.ignored:appVersion",
222
- IngestError(status: 0))
227
+ config.onError("config.ignored:appVersion", AdvenueSetupError.appVersionIgnored)
223
228
  }
224
229
 
225
230
  // B4: kuyruk blob'u Caches dosyalarına (yedek dışı); UserDefaults'ta
@@ -230,7 +235,7 @@ final class FacadeState: @unchecked Sendable {
230
235
  store = CompositeStore(files: files)
231
236
  } else {
232
237
  store = UserDefaultsStore()
233
- config.onError("store.cache_unavailable", IngestError(status: 0))
238
+ config.onError("store.cache_unavailable", AdvenueSetupError.cacheUnavailable)
234
239
  }
235
240
  let secure = KeychainStore()
236
241
  let uuid = SystemUUIDs()
@@ -240,7 +245,7 @@ final class FacadeState: @unchecked Sendable {
240
245
  // Deferred: the Keychain could not be read. Nothing is minted and
241
246
  // nothing starts, so no event carries an invented identifier. The next
242
247
  // launch after first unlock resolves it.
243
- config.onError("identity.deferred", IngestError(status: 0))
248
+ config.onError("identity.deferred", AdvenueSetupError.identityDeferred)
244
249
  return
245
250
  }
246
251
 
@@ -251,14 +256,19 @@ final class FacadeState: @unchecked Sendable {
251
256
  osVersion = nil
252
257
  #endif
253
258
 
254
- let eventTransport =
259
+ let baseTransport =
255
260
  transport
256
261
  ?? HttpTransport(
257
262
  endpoint: config.endpoint, apiKey: config.apiKey,
258
263
  signingSecret: config.signingSecret,
259
264
  // Diagnostics only: it runs after the batch is already accepted, so
260
265
  // nothing it does can turn a successful ingest into a failure.
261
- onAccepted: { [acceptedAppId] appId in acceptedAppId.set(appId) })
266
+ onAccepted: { [acceptedAppId] appId in
267
+ // A key pasted from the wrong app is otherwise silent.
268
+ if acceptedAppId.set(appId) { log("[Advenue] ingesting into app \(appId)") }
269
+ })
270
+ let eventTransport: any EventTransport =
271
+ config.debug ? LoggingTransport(inner: baseTransport, log: log) : baseTransport
262
272
 
263
273
  let engine = AdvenueEngine(
264
274
  config: EngineConfig(
@@ -267,7 +277,7 @@ final class FacadeState: @unchecked Sendable {
267
277
  osVersion: osVersion,
268
278
  sdkVersion: String((config.sdkVersion ?? AdvenueVersion.current).prefix(32)),
269
279
  requireConsent: config.requireConsent, sessionWindowMs: config.sessionWindowMs,
270
- batchSize: config.batchSize),
280
+ batchSize: config.batchSize, piiScrubEnabled: config.piiScrubEnabled),
271
281
  store: store, clock: SystemClock(), scheduler: TimerScheduler(), uuid: uuid,
272
282
  transport: eventTransport,
273
283
  onError: config.onError)
@@ -330,13 +340,32 @@ final class FacadeState: @unchecked Sendable {
330
340
  pendingDeepLinks = []
331
341
  lock.unlock()
332
342
 
343
+ // Before anything else is recorded: a launch that still owes its install
344
+ // stamps it now and holds every flush until the install is enqueued, so
345
+ // the server never sees this launch's session or events ahead of it. The
346
+ // hold outlives the enrichment deadline by a margin and then lapses.
347
+ pipe.submit(.beginInstall(holdMs: INSTALL_WINDOW_MS + INSTALL_HOLD_MARGIN_MS))
348
+
333
349
  // Replayed in arrival order, before the first session, so a deferred deep
334
350
  // link is attributed to the launch it belongs to.
335
351
  for url in buffered { send(url) }
336
- pipe.submit(.foreground)
352
+ // A launch into the background (fetch, silent push, location) is not a
353
+ // session: the first didBecomeActive opens it instead. iOS posts no
354
+ // didEnterBackground for an app that never left the background, so a
355
+ // tracker seeded as foregrounded would swallow the user's real open.
356
+ let inForeground = !launchedInBackground()
357
+ if inForeground { pipe.submit(.foreground) }
337
358
  // Seeded to match: the line above IS this launch's foreground, so the
338
359
  // activation notification that follows must not open a second session.
339
- observeLifecycle(seededInForeground: true)
360
+ observeLifecycle(seededInForeground: inForeground)
361
+
362
+ log(
363
+ "[Advenue] initialized — endpoint \(config.endpoint), "
364
+ + "sdk \(config.sdkVersion ?? AdvenueVersion.current), "
365
+ // Not "background launch": a scene-based (SwiftUI) app is still
366
+ // reported in the background during didFinishLaunching even when the
367
+ // user opened it. Either way the session opens on activation.
368
+ + (inForeground ? "launched active" : "not active yet, the session opens on activation"))
340
369
 
341
370
  if config.flushIntervalMs > 0 {
342
371
  let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility))
@@ -593,6 +622,12 @@ final class FacadeState: @unchecked Sendable {
593
622
  #endif
594
623
  }
595
624
 
625
+ /// The public lifecycle calls go through the same tracker as UIKit's signals,
626
+ /// so one forwarded while already foregrounded (or already backgrounded) is a
627
+ /// no-op instead of a session split.
628
+ func notifyForeground() { handle(.didBecomeActive) }
629
+ func notifyBackground() { handle(.didEnterBackground) }
630
+
596
631
  /// Applies one lifecycle signal. Public routing lives here rather than in the
597
632
  /// observer closure so a test can drive it without UIKit.
598
633
  func handle(_ signal: LifecycleSignal) {
@@ -8,7 +8,7 @@ public struct AdvenueConfig: Sendable {
8
8
  public var appVersion: String?
9
9
  public var requireConsent: Bool
10
10
  public var sessionWindowMs: Int64
11
- /// Events per request. Matches sdk-core.
11
+ /// Events per request. Matches the other SDKs.
12
12
  public var batchSize: Int
13
13
  /// Auto-flush period. Zero disables the timer, which is what tests want and
14
14
  /// no shipping app does.
@@ -29,6 +29,16 @@ public struct AdvenueConfig: Sendable {
29
29
  /// Called when the SDK swallows a best-effort failure. Never receives PII.
30
30
  public var onError: @Sendable (String, any Error) -> Void
31
31
 
32
+ /// Development aid: logs the SDK's start, every failure `onError` sees and
33
+ /// each accepted batch to the unified log (subsystem `io.advenue.sdk`) —
34
+ /// Xcode's console and Console.app. Never logs an identifier or a payload.
35
+ /// Leave off in production; route failures through `onError` instead.
36
+ public var debug: Bool
37
+
38
+ /// B1: track/install properties PII scrub. On by default; disable only
39
+ /// explicitly (documented risk — raw PII reaches ingest). Same as Android.
40
+ public var piiScrubEnabled: Bool
41
+
32
42
  /// Overrides the version stamped on every event. Set by a WRAPPER SDK, never
33
43
  /// by an app.
34
44
  ///
@@ -61,7 +71,9 @@ public struct AdvenueConfig: Sendable {
61
71
  conversionValues: ConversionValueConfig? = nil,
62
72
  onError: @escaping @Sendable (String, any Error) -> Void = { _, _ in },
63
73
  sdkVersion: String? = nil,
64
- allowInsecureHttp: Bool = false
74
+ allowInsecureHttp: Bool = false,
75
+ debug: Bool = false,
76
+ piiScrubEnabled: Bool = true
65
77
  ) {
66
78
  precondition(
67
79
  allowInsecureHttp || Self.isSecureEndpoint(endpoint),
@@ -70,6 +82,7 @@ public struct AdvenueConfig: Sendable {
70
82
  self.endpoint = endpoint
71
83
  self.appVersion = appVersion
72
84
  self.requireConsent = requireConsent
85
+ self.debug = debug
73
86
  self.sessionWindowMs = sessionWindowMs
74
87
  self.batchSize = batchSize
75
88
  self.flushIntervalMs = flushIntervalMs
@@ -77,6 +90,7 @@ public struct AdvenueConfig: Sendable {
77
90
  self.conversionValues = conversionValues
78
91
  self.onError = onError
79
92
  self.sdkVersion = sdkVersion
93
+ self.piiScrubEnabled = piiScrubEnabled
80
94
  }
81
95
  }
82
96
 
@@ -85,5 +99,5 @@ public struct AdvenueConfig: Sendable {
85
99
  /// release tag unless something checks. CI does, because the first question
86
100
  /// every field report raises is which build produced the event.
87
101
  public enum AdvenueVersion {
88
- public static let current = "1.0.0"
102
+ public static let current = "1.1.0"
89
103
  }
@@ -0,0 +1,24 @@
1
+ /// A condition met while starting the SDK, reported through `onError` under
2
+ /// the context in parentheses. Not a transport failure — these used to arrive
3
+ /// as `IngestError(status: 0)`, which read as an HTTP error that never
4
+ /// happened and named no cause.
5
+ public enum AdvenueSetupError: Error, Equatable, Sendable, CustomStringConvertible {
6
+ /// `config.ignored:appVersion`
7
+ case appVersionIgnored
8
+ /// `store.cache_unavailable`
9
+ case cacheUnavailable
10
+ /// `identity.deferred`
11
+ case identityDeferred
12
+
13
+ public var description: String {
14
+ switch self {
15
+ case .appVersionIgnored:
16
+ return "appVersion is read from the bundle; the config value has no effect"
17
+ case .cacheUnavailable:
18
+ return "the Caches directory is unavailable; the event queue falls back to UserDefaults"
19
+ case .identityDeferred:
20
+ return "the Keychain is unavailable (not unlocked since boot, or the app is not "
21
+ + "code-signed); nothing starts until the next launch that can read it"
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,46 @@
1
+ import Foundation
2
+ import os
3
+
4
+ /// Where `AdvenueConfig.debug` writes. The unified logging system in an app;
5
+ /// a recorder in tests.
6
+ protocol AdvenueLogSink: Sendable {
7
+ func log(_ message: String)
8
+ }
9
+
10
+ /// `os.Logger`, public privacy: the lines carry failure contexts, counts and
11
+ /// the app id the server resolved — never an identifier or a payload — and a
12
+ /// debug aid that Console.app shows as `<private>` would not be one.
13
+ struct UnifiedLogSink: AdvenueLogSink {
14
+ private let logger = Logger(subsystem: "io.advenue.sdk", category: "Advenue")
15
+ func log(_ message: String) {
16
+ logger.notice("\(message, privacy: .public)")
17
+ }
18
+ }
19
+
20
+ /// Logs each accepted batch; failures already reach the log through the
21
+ /// wrapped `onError` (`flush.transport`, `flush.poison`).
22
+ struct LoggingTransport: EventTransport {
23
+ let inner: any EventTransport
24
+ let log: @Sendable (String) -> Void
25
+
26
+ func send(_ events: [ClientEvent]) async throws {
27
+ try await inner.send(events)
28
+ log("[Advenue] sent \(events.count) event(s)")
29
+ }
30
+ }
31
+
32
+ /// With `debug` on: the config with `onError` also writing to the log, and the
33
+ /// log itself. Off: the config unchanged and a log that discards.
34
+ func withDebugLogging(
35
+ _ config: AdvenueConfig, sink: (any AdvenueLogSink)?
36
+ ) -> (AdvenueConfig, @Sendable (String) -> Void) {
37
+ guard config.debug else { return (config, { _ in }) }
38
+ let sink = sink ?? UnifiedLogSink()
39
+ var logged = config
40
+ let report = config.onError
41
+ logged.onError = { context, error in
42
+ sink.log("[Advenue] \(context): \(error)")
43
+ report(context, error)
44
+ }
45
+ return (logged, { sink.log($0) })
46
+ }
@@ -3,9 +3,23 @@ import Foundation
3
3
  /// A non-2xx ingest response. `isRetryable` separates a transient failure from
4
4
  /// a poison payload: retrying a 400 forever would block the queue head, and
5
5
  /// dropping a 429 would discard events over a throttle.
6
- public struct IngestError: Error, Equatable, Sendable {
6
+ public struct IngestError: Error, Equatable, Sendable, CustomStringConvertible {
7
7
  public let status: Int
8
- public init(status: Int) { self.status = status }
8
+ /// Set when no response arrived at all — DNS, refused connection, timeout,
9
+ /// no network. `status` is then 408 so the backoff retries it, but that 408
10
+ /// was never sent by a server, and logging it as one sends whoever debugs it
11
+ /// looking at the wrong end (F-SDK-10).
12
+ public let networkCause: String?
13
+
14
+ public init(status: Int, networkCause: String? = nil) {
15
+ self.status = status
16
+ self.networkCause = networkCause
17
+ }
18
+
19
+ public var description: String {
20
+ if let networkCause { return "IngestError: no response (network error: \(networkCause))" }
21
+ return "IngestError(status: \(status))"
22
+ }
9
23
 
10
24
  public var isRetryable: Bool {
11
25
  status == 408 || status == 429 || status >= 500
@@ -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" { 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()
208
251
 
209
252
  var event = ClientEvent(
210
253
  id: uuid.next(), deviceId: config.deviceId, type: "install", name: "install",
211
- timestamp: EventEncoding.iso8601(ms: clock.nowMs()), platform: config.platform)
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
- 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)
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 { return }
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() { 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: [