@advenue/react-native 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +4 -0
  2. package/android/src/main/kotlin/io/advenue/Advenue.kt +49 -9
  3. package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +9 -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 +28 -2
  7. package/android/src/main/kotlin/io/advenue/core/Engine.kt +73 -3
  8. package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +12 -0
  9. package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +109 -33
  10. package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +24 -10
  11. package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +9 -2
  12. package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +21 -8
  13. package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +7 -0
  14. package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +23 -1
  15. package/dist/index.cjs +9 -2
  16. package/dist/index.d.cts +13 -3
  17. package/dist/index.d.ts +13 -3
  18. package/dist/index.js +9 -2
  19. package/ios/AdvenueIosModule.swift +4 -1
  20. package/ios/vendor/Advenue/Advenue.swift +52 -17
  21. package/ios/vendor/Advenue/AdvenueConfig.swift +10 -2
  22. package/ios/vendor/Advenue/AdvenueSetupError.swift +24 -0
  23. package/ios/vendor/Advenue/DebugLog.swift +46 -0
  24. package/ios/vendor/AdvenueCore/Backoff.swift +16 -2
  25. package/ios/vendor/AdvenueCore/Engine.swift +58 -4
  26. package/ios/vendor/AdvenueCore/EventQueue.swift +12 -0
  27. package/ios/vendor/AdvenueCore/SessionTracker.swift +89 -32
  28. package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +14 -2
  29. package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +14 -3
  30. package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +19 -0
  31. package/ios/vendor/AdvenuePlatform/HttpTransport.swift +28 -4
  32. package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +4 -0
  33. package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +14 -2
  34. package/package.json +9 -9
  35. package/src/index.ts +11 -1
  36. package/src/types.ts +13 -3
@@ -57,6 +57,10 @@ class AdvenueAndroidModule : Module() {
57
57
  // The JavaScript has sent this since before the inversion; nothing
58
58
  // read it, so Meta install-referrer attribution never ran.
59
59
  fbAppId = options["fbAppId"] as? String,
60
+ // Logs the start, failures and sent batches to logcat (tag Advenue).
61
+ debug = (options["debug"] as? Boolean) ?: false,
62
+ // Test only: an http:// endpoint is refused without it.
63
+ allowInsecureHttp = (options["allowInsecureHttp"] as? Boolean) ?: false,
60
64
  ),
61
65
  )
62
66
  }
@@ -19,6 +19,8 @@ import io.advenue.core.SystemClock
19
19
  import io.advenue.core.SystemUuids
20
20
  import io.advenue.core.TimerScheduler
21
21
  import io.advenue.platform.HttpConversionFetcher
22
+ import io.advenue.platform.INSTALL_HOLD_MARGIN_MS
23
+ import io.advenue.platform.REFERRER_DEADLINE_MS
22
24
  import io.advenue.platform.HttpUrlTransport
23
25
  import io.advenue.platform.IdentityResolution
24
26
  import io.advenue.platform.EnrichmentSources
@@ -31,6 +33,7 @@ import io.advenue.core.tcfToConsent
31
33
  import io.advenue.platform.collectDeviceInfo
32
34
  import io.advenue.platform.readTcf
33
35
  import io.advenue.platform.readAppVersion
36
+ import io.advenue.platform.readLaunchedInForeground
34
37
  import io.advenue.platform.collectEnrichment
35
38
  import io.advenue.platform.sha256Hex
36
39
  import io.advenue.platform.resolveIdentity
@@ -250,13 +253,19 @@ internal class FacadeState {
250
253
  */
251
254
  fun start(
252
255
  application: Application,
253
- config: AdvenueConfig,
256
+ requested: AdvenueConfig,
254
257
  transport: EventTransport? = null,
255
258
  sources: EnrichmentSources? = null,
259
+ launchedInForeground: () -> Boolean = ::readLaunchedInForeground,
260
+ logSink: AdvenueLogSink? = null,
256
261
  ) {
257
262
  // Replace-and-shut-down, never add.
258
263
  stop()
259
264
 
265
+ // `debug`: every swallowed failure goes through onError, so logging there
266
+ // covers all of them — wrapped once, before anything below captures it.
267
+ val (config, log) = withDebugLogging(requested, logSink)
268
+
260
269
  // The SDK resolves the app version itself (readAppVersion); a config value
261
270
  // is ignored — and said so, rather than looking like it took effect.
262
271
  if (config.appVersion != null) {
@@ -275,12 +284,21 @@ internal class FacadeState {
275
284
  // and nothing starts, so no event carries an invented identifier. The
276
285
  // receiver below is the exit — without it the deferral would trade a
277
286
  // phantom id for permanent silence.
278
- config.onError("identity.deferred", IngestError(0))
279
- registerUnlockRetry(application, config, transport, sources)
287
+ // Reported as what it is: an IngestError(0) read as an HTTP failure
288
+ // that never happened, naming no cause.
289
+ config.onError(
290
+ "identity.deferred",
291
+ IllegalStateException(
292
+ "credential-encrypted storage is locked (not unlocked since boot); " +
293
+ "the SDK starts when the user unlocks the device",
294
+ ),
295
+ )
296
+ // The unwrapped config: the retry wraps it again on its own start.
297
+ registerUnlockRetry(application, requested, transport, sources)
280
298
  return
281
299
  }
282
300
 
283
- val eventTransport =
301
+ val baseTransport =
284
302
  transport
285
303
  ?: HttpUrlTransport(
286
304
  endpoint = config.endpoint,
@@ -289,8 +307,14 @@ internal class FacadeState {
289
307
  clock = SystemClock(),
290
308
  // Diagnostics only: it runs after the batch is already accepted, so
291
309
  // nothing it does can turn a successful ingest into a failure.
292
- onAccepted = { appId -> resolvedAppId = appId },
310
+ onAccepted = { appId ->
311
+ // A key pasted from the wrong app is otherwise silent.
312
+ if (resolvedAppId != appId) log("[Advenue] ingesting into app $appId")
313
+ resolvedAppId = appId
314
+ },
293
315
  )
316
+ val eventTransport =
317
+ if (config.debug) LoggingTransport(baseTransport, log) else baseTransport
294
318
 
295
319
  val scheduler = TimerScheduler()
296
320
  val engine =
@@ -339,10 +363,19 @@ internal class FacadeState {
339
363
  pendingDeepLinks = mutableListOf()
340
364
  }
341
365
 
366
+ // Before anything else is recorded: a launch that still owes its install
367
+ // stamps it now and holds every flush until the install is enqueued, so
368
+ // the server never sees this launch's session or events ahead of it. The
369
+ // hold outlives the referrer deadline by a margin and then lapses.
370
+ pipe.submit(Command.BeginInstall(REFERRER_DEADLINE_MS + INSTALL_HOLD_MARGIN_MS))
371
+
342
372
  // Replayed in arrival order, before the first session, so a deferred deep
343
373
  // link is attributed to the launch it belongs to.
344
374
  buffered.forEach { sendDeepLink(it) }
345
375
 
376
+ // A process woken with no activity (a push, a WorkManager job, a
377
+ // broadcast) is not a session: the first activity start opens it instead.
378
+ val inForeground = launchedInForeground()
346
379
  val bridge =
347
380
  LifecycleBridge(
348
381
  application,
@@ -353,12 +386,19 @@ internal class FacadeState {
353
386
  // for hours.
354
387
  pipe.submit(Command.Flush)
355
388
  },
389
+ // Seeded to match: the Foreground below IS this launch's session, so
390
+ // the launcher activity's start that follows must not open a second
391
+ // one — the engine would read the open session as a killed app and
392
+ // split it with a synthetic session_end.
393
+ seededInForeground = inForeground,
356
394
  )
357
395
  bridge.start()
358
- // Application.onCreate runs before the first activity starts, so the
359
- // foreground signal arrives from the bridge; a cold start where the SDK is
360
- // initialised from an already-running activity still needs this.
361
- pipe.submit(Command.Foreground)
396
+ if (inForeground) pipe.submit(Command.Foreground)
397
+ log(
398
+ "[Advenue] initialized — endpoint ${config.endpoint}, " +
399
+ "sdk ${config.sdkVersion ?: AdvenueVersion.CURRENT}, " +
400
+ if (inForeground) "foreground launch" else "background launch, no session yet",
401
+ )
362
402
 
363
403
  // Enrichment then install, off the caller's thread. initialize() returns
364
404
  // synchronously — an SDK that blocks Application.onCreate for five seconds
@@ -64,6 +64,12 @@ constructor(
64
64
  * API anahtarı dahil tüm olayları şifresiz taşır.
65
65
  */
66
66
  val allowInsecureHttp: Boolean = false,
67
+ /**
68
+ * Development aid: logs the SDK's start, every failure [onError] sees and each
69
+ * accepted batch to logcat (tag `Advenue`). Never logs an identifier or a
70
+ * payload. Leave off in production; route failures through [onError] instead.
71
+ */
72
+ val debug: Boolean = false,
67
73
  ) {
68
74
  init {
69
75
  // B5: şemasız endpoint fail-fast — geçersiz şema transportta değil,
@@ -125,6 +131,8 @@ constructor(
125
131
  config = config.copy(allowInsecureHttp = value)
126
132
  }
127
133
 
134
+ public fun debug(value: Boolean): Builder = apply { config = config.copy(debug = value) }
135
+
128
136
  public fun build(): AdvenueConfig = config
129
137
  }
130
138
  }
@@ -137,5 +145,5 @@ constructor(
137
145
  * the event.
138
146
  */
139
147
  public object AdvenueVersion {
140
- public const val CURRENT: String = "0.1.0"
148
+ public const val CURRENT: String = "1.1.0"
141
149
  }
@@ -0,0 +1,51 @@
1
+ package io.advenue
2
+
3
+ import android.util.Log
4
+ import io.advenue.core.ClientEvent
5
+ import io.advenue.core.EventTransport
6
+
7
+ /** Where [AdvenueConfig.debug] writes: logcat in an app, a recorder in tests. */
8
+ internal fun interface AdvenueLogSink {
9
+ fun log(message: String)
10
+ }
11
+
12
+ /** Logcat, tag `Advenue`. The lines carry failure contexts, counts and the app
13
+ * id the server resolved — never an identifier or a payload. */
14
+ internal object LogcatSink : AdvenueLogSink {
15
+ override fun log(message: String) {
16
+ Log.i("Advenue", message)
17
+ }
18
+ }
19
+
20
+ /** Logs each accepted batch; failures already reach the log through the
21
+ * wrapped `onError` (`flush.transport`, `flush.poison`). */
22
+ internal class LoggingTransport(
23
+ private val inner: EventTransport,
24
+ private val log: (String) -> Unit,
25
+ ) : EventTransport {
26
+ override fun send(events: List<ClientEvent>) {
27
+ inner.send(events)
28
+ log("[Advenue] sent ${events.size} event(s)")
29
+ }
30
+ }
31
+
32
+ /**
33
+ * With `debug` on: the config with `onError` also writing to the log, and the
34
+ * log itself. Off: the config unchanged and a log that discards.
35
+ */
36
+ internal fun withDebugLogging(
37
+ config: AdvenueConfig,
38
+ sink: AdvenueLogSink?,
39
+ ): Pair<AdvenueConfig, (String) -> Unit> {
40
+ if (!config.debug) return config to {}
41
+ val out = sink ?: LogcatSink
42
+ val report = config.onError
43
+ val logged =
44
+ config.copy(
45
+ onError = { context, error ->
46
+ out.log("[Advenue] $context: $error")
47
+ report(context, error)
48
+ },
49
+ )
50
+ return logged to { message -> out.log(message) }
51
+ }
@@ -5,7 +5,20 @@ package io.advenue.core
5
5
  * poison payload: retrying a 400 forever would block the queue head, and
6
6
  * dropping a 429 would discard events over a throttle.
7
7
  */
8
- internal class IngestError(public val status: Int) : Exception("advenue ingest failed: $status") {
8
+ internal class IngestError(
9
+ public val status: Int,
10
+ /**
11
+ * Set when no response arrived at all — DNS, refused connection, timeout, no
12
+ * network. [status] is then 408 so the backoff retries it, but that 408 was
13
+ * never sent by a server, and logging it as one sends whoever debugs it
14
+ * looking at the wrong end (F-SDK-10).
15
+ */
16
+ public val networkCause: String? = null,
17
+ ) :
18
+ Exception(
19
+ if (networkCause != null) "advenue ingest failed: no response (network error: $networkCause)"
20
+ else "advenue ingest failed: $status",
21
+ ) {
9
22
  public val isRetryable: Boolean
10
23
  get() = status == 408 || status == 429 || status >= 500
11
24
  }
@@ -27,6 +27,9 @@ internal sealed class Command {
27
27
 
28
28
  public data class TrackInstall(val properties: Map<String, Any?>?) : Command()
29
29
 
30
+ /** A launch that still owes its install: hold flushes for at most [holdMs]. */
31
+ internal data class BeginInstall(val holdMs: Long) : Command()
32
+
30
33
  internal data class SetConsentData(val consent: Consent?) : Command()
31
34
 
32
35
  internal data class SetPushToken(val token: String?, val provider: String?) : Command()
@@ -131,23 +134,46 @@ internal class CommandPipe(
131
134
  )
132
135
  is Command.SetAppInstanceId -> engine.setAppInstanceId(command.id)
133
136
  is Command.TrackInstall -> engine.trackInstall(command.properties)
137
+ is Command.BeginInstall -> engine.beginInstall(command.holdMs)
134
138
  is Command.SetConsentData -> engine.setConsentData(command.consent)
135
139
  is Command.SetPushToken -> engine.setPushToken(command.token, command.provider)
136
140
  Command.ForgetMe -> engine.forgetMe()
137
141
  Command.Foreground -> engine.notifyForeground()
138
142
  Command.Background -> engine.notifyBackground()
139
- Command.Flush -> engine.beginFlush()?.let { sends.put(Send(it, single = null)) }
143
+ Command.Flush -> {
144
+ val batch = engine.beginFlush()
145
+ if (batch != null) {
146
+ sends.put(Send(batch, single = null))
147
+ } else if (engine.isFlushing()) {
148
+ // Asked for while a batch is on the wire: run it when that send
149
+ // finishes rather than drop it — the events tracked meanwhile would
150
+ // otherwise wait a whole auto-flush interval.
151
+ flushRequested = true
152
+ }
153
+ }
140
154
  is Command.BatchOutcome ->
141
155
  engine.onBatchOutcome(command.error)?.let { sends.put(Send(listOf(it), single = it)) }
156
+ ?: flushIfRequested()
142
157
  is Command.SingleOutcome ->
143
158
  engine.onSingleOutcome(command.event, command.error)?.let {
144
159
  sends.put(Send(listOf(it), single = it))
145
- }
160
+ } ?: flushIfRequested()
146
161
  is Command.RunScheduled -> command.work()
147
162
  Command.Shutdown -> Unit
148
163
  }
149
164
  }
150
165
 
166
+ /** Core-thread only, like everything [handle] touches. */
167
+ private var flushRequested = false
168
+
169
+ /** The flush a send in flight deferred, once that send is over. After a
170
+ * transient failure the backoff makes this a no-op: the timer retries. */
171
+ private fun flushIfRequested() {
172
+ if (!flushRequested || engine.isFlushing()) return
173
+ flushRequested = false
174
+ engine.beginFlush()?.let { sends.put(Send(it, single = null)) }
175
+ }
176
+
151
177
  /**
152
178
  * Synchronous, non-blocking and **ordered** — the entire reason the ingress
153
179
  * is a queue.
@@ -80,6 +80,19 @@ internal class AdvenueEngine(
80
80
  private var consentData: Consent? = readConsentData()
81
81
  private var pushToken: String? = null
82
82
  private var pushProvider: String? = null
83
+ /**
84
+ * F-SDK-3: when this launch began an install that is not recorded yet. The
85
+ * install is stamped with it — the first open, not the moment the referrer
86
+ * read that preceded it finished.
87
+ */
88
+ private var installStartedAtMs: Long? = null
89
+
90
+ /**
91
+ * Flushes are held until the install is enqueued, so nothing reaches the
92
+ * server ahead of it. Bounded: past this instant the hold lapses, because a
93
+ * lost enrichment thread must never strand the queue.
94
+ */
95
+ private var installHoldUntilMs: Long? = null
83
96
  private var flushing = false
84
97
  private var consecutiveFailures = 0
85
98
  private var backoffUntilMs = 0L
@@ -98,6 +111,9 @@ internal class AdvenueEngine(
98
111
  // B1: PII scrub (Swift PIIScrub ile aynı sözleşme).
99
112
  val scrubbed = if (config.piiScrubEnabled) PiiScrub.scrub(properties) else properties
100
113
  queue.enqueue(newEvent(type = type, name = name, properties = scrubbed))
114
+ // An event is proof of life: a relaunch after an OS kill measures the gap
115
+ // from the last one (F-SDK-7).
116
+ sessions.heartbeat()
101
117
  return true
102
118
  }
103
119
 
@@ -202,15 +218,55 @@ internal class AdvenueEngine(
202
218
  * failure.
203
219
  */
204
220
  public fun trackInstall(properties: Map<String, Any?>? = null): Boolean {
221
+ val startedAtMs = installStartedAtMs
222
+ // Every outcome below ends the wait: recorded, refused or already sent,
223
+ // nothing is coming that the held events should still wait for.
224
+ releaseInstallHold()
205
225
  if (forgotten || (config.requireConsent && !consent)) return false
206
226
  if (store.getString(INSTALL_SENT_KEY) == "1") return false
207
227
  // B1: install properties de scrub kapsamındadır.
208
228
  val scrubbed = if (config.piiScrubEnabled) PiiScrub.scrub(properties) else properties
209
- queue.enqueue(newEvent(type = "install", name = "install", properties = scrubbed))
229
+ // At the head: the session and custom events recorded while enrichment ran
230
+ // are already queued, and the install must reach the server before them.
231
+ queue.enqueueFirst(
232
+ newEvent(
233
+ type = "install",
234
+ name = "install",
235
+ properties = scrubbed,
236
+ timestampMs = startedAtMs ?: clock.nowMs(),
237
+ ),
238
+ )
210
239
  store.setString(INSTALL_SENT_KEY, "1")
211
240
  return true
212
241
  }
213
242
 
243
+ /**
244
+ * Marks the start of a launch that still owes its install. Called by the
245
+ * facade BEFORE the launch's foreground, while enrichment (Play referrer,
246
+ * advertising id, attestation) runs. Adjust and AppsFlyer both hold every
247
+ * later package behind the first one; this does the same, for at most
248
+ * [holdMs].
249
+ */
250
+ public fun beginInstall(holdMs: Long) {
251
+ if (forgotten || store.getString(INSTALL_SENT_KEY) == "1") return
252
+ val now = clock.nowMs()
253
+ installStartedAtMs = now
254
+ installHoldUntilMs = now + holdMs
255
+ }
256
+
257
+ /** Whether a pending install still holds flushes back. */
258
+ private fun heldForInstall(): Boolean {
259
+ val until = installHoldUntilMs ?: return false
260
+ if (clock.nowMs() < until) return true
261
+ installHoldUntilMs = null
262
+ return false
263
+ }
264
+
265
+ private fun releaseInstallHold() {
266
+ installStartedAtMs = null
267
+ installHoldUntilMs = null
268
+ }
269
+
214
270
  /**
215
271
  * Erasure, including the install-scoped identifiers.
216
272
  *
@@ -228,6 +284,7 @@ internal class AdvenueEngine(
228
284
  */
229
285
  public fun forgetMe() {
230
286
  forgotten = true
287
+ releaseInstallHold()
231
288
  queue.clear()
232
289
  sessions.reset()
233
290
  consent = false
@@ -290,9 +347,21 @@ internal class AdvenueEngine(
290
347
  /** Whether a transport is wired at all. */
291
348
  internal fun canFlush(): Boolean = transport != null
292
349
 
350
+ /** Whether a batch is on the wire (its outcome not yet consumed). */
351
+ internal fun isFlushing(): Boolean = flushing
352
+
293
353
  /** Decides what to send. Null means there is nothing to do. */
294
354
  internal fun beginFlush(): List<ClientEvent>? {
295
- if (transport == null || flushing || queue.size == 0 || clock.nowMs() < backoffUntilMs) {
355
+ // The auto-flush timer is the foreground tick: it keeps the open
356
+ // sub-session's last activity fresh even when the app records nothing.
357
+ if (!forgotten) sessions.heartbeat()
358
+ if (
359
+ transport == null ||
360
+ flushing ||
361
+ queue.size == 0 ||
362
+ clock.nowMs() < backoffUntilMs ||
363
+ heldForInstall()
364
+ ) {
296
365
  return null
297
366
  }
298
367
  // Clamped to the wire's limit, not trusted. The server answers 400 for a
@@ -408,13 +477,14 @@ internal class AdvenueEngine(
408
477
  type: String,
409
478
  name: String,
410
479
  properties: Map<String, Any?>?,
480
+ timestampMs: Long = clock.nowMs(),
411
481
  ): ClientEvent =
412
482
  ClientEvent(
413
483
  id = uuid.next(),
414
484
  deviceId = config.deviceId,
415
485
  type = type,
416
486
  name = name,
417
- timestamp = iso8601(clock.nowMs()),
487
+ timestamp = iso8601(timestampMs),
418
488
  platform = config.platform,
419
489
  installationId = config.installationId,
420
490
  appVersion = config.appVersion,
@@ -35,6 +35,18 @@ internal class EventQueue(
35
35
  schedulePersist()
36
36
  }
37
37
 
38
+ /**
39
+ * Puts an event at the HEAD of the queue — the install, which is the first
40
+ * package of an installation and must not be preceded by the session and
41
+ * custom events recorded while enrichment ran. Over the cap, the oldest
42
+ * events behind it are dropped, never the event just placed.
43
+ */
44
+ public fun enqueueFirst(event: ClientEvent) {
45
+ events.add(0, event)
46
+ while (events.size > maxSize) events.removeAt(1)
47
+ schedulePersist()
48
+ }
49
+
38
50
  public fun peek(max: Int): List<ClientEvent> = events.take(max)
39
51
 
40
52
  public fun ack(sent: List<ClientEvent>) {
@@ -5,6 +5,12 @@ internal const val SESSION_STATE_KEY: String = "advenue.session"
5
5
  /** 30 minutes. */
6
6
  internal const val DEFAULT_SESSION_WINDOW_MS: Long = 1_800_000L
7
7
 
8
+ /**
9
+ * The heartbeat persists at most this often: it runs on every recorded event,
10
+ * and a chatty app must not turn that into a storage write per event.
11
+ */
12
+ internal const val SESSION_HEARTBEAT_MIN_INTERVAL_MS: Long = 1_000L
13
+
8
14
  /** A session lifecycle event, wrapped by the engine as a `type: "session"` event. */
9
15
  internal data class SessionEvent(val name: String, val properties: Map<String, Any?>)
10
16
 
@@ -16,6 +22,12 @@ private data class SessionState(
16
22
  val activeStart: Long?,
17
23
  val firstForegroundAt: Long,
18
24
  val timeSpentMs: Long,
25
+ /**
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
+ */
30
+ val lastActiveAt: Long? = null,
19
31
  ) {
20
32
  /**
21
33
  * The persisted blob. Field names are frozen: a rename would make RN-written
@@ -30,6 +42,7 @@ private data class SessionState(
30
42
  "activeStart" to activeStart,
31
43
  "firstForegroundAt" to firstForegroundAt,
32
44
  "timeSpentMs" to timeSpentMs,
45
+ "lastActiveAt" to lastActiveAt,
33
46
  )
34
47
  }
35
48
 
@@ -46,21 +59,88 @@ internal class SessionTracker(
46
59
  ) {
47
60
  private var lastState: SessionState? = null
48
61
 
62
+ /**
63
+ * True once THIS process opened the current sub-session (X-SDK-1). Never
64
+ * persisted: a relaunched process starts false, so its tracks and flushes
65
+ * before [handleForeground] cannot keep the dead process's sub-session alive
66
+ * and move its kill time to the relaunch.
67
+ */
68
+ private var ownsOpenSubSession = false
69
+
49
70
  /** Drops the cached state (erasure) so a wiped store cannot be resurrected. */
50
71
  public fun reset() {
51
72
  lastState = null
73
+ ownsOpenSubSession = false
52
74
  }
53
75
 
54
76
  /**
55
- * Foreground transition, including cold start. Returns:
56
- * - empty — a sub-session inside the window;
57
- * - `[session_start]` — a new session;
58
- * - `[session_end, session_start]` — kill recovery: the prior session was
59
- * still open when the OS killed the app, so its end is synthesised first.
77
+ * Refreshes the open sub-session's last known activity. Called by the engine
78
+ * on every recorded event and every flush — the auto-flush timer is the
79
+ * foreground tick. It is what a relaunch after an OS kill measures the gap
80
+ * from, the role Adjust's `lastActivity` plays.
81
+ *
82
+ * A no-op until this process has run [handleForeground]: only the process
83
+ * that opened the sub-session may extend it. Otherwise an event or flush in a
84
+ * relaunched process (a background wake from a broadcast, push or WorkManager
85
+ * before the activity resumes) would stamp the killed sub-session alive "now"
86
+ * and count the dead time as active (X-SDK-1).
87
+ */
88
+ public fun heartbeat() {
89
+ if (!ownsOpenSubSession) return
90
+ val state = load() ?: return
91
+ val activeStart = state.activeStart ?: return
92
+ val now = clock.nowMs()
93
+ if (now - (state.lastActiveAt ?: activeStart) < SESSION_HEARTBEAT_MIN_INTERVAL_MS) return
94
+ save(state.copy(lastActiveAt = now))
95
+ }
96
+
97
+ /**
98
+ * Foreground transition, including cold start. Returns the events to emit, in
99
+ * order:
100
+ * - a synthetic `session_end` first when a sub-session was still open — the
101
+ * OS killed the app before it could background. It is closed at its last
102
+ * heartbeat, not at the relaunch: the time the app was dead is not active
103
+ * time. A consumer must never see two sessions open, so it comes first.
104
+ * - then, measured from the background (or that kill time): nothing for a
105
+ * sub-session inside the window, or a `session_start` for a new session.
106
+ *
107
+ * A kill is not a session boundary by itself (F-SDK-7). Adjust's rule — a new
108
+ * session only after the session interval of inactivity — applies to a
109
+ * relaunch exactly as it does to a return from the background.
60
110
  */
61
111
  public fun handleForeground(): List<SessionEvent> {
62
- val state = load()
63
112
  val now = clock.nowMs()
113
+ val state = load()
114
+ val activeStart = state?.activeStart ?: return resume(state, now)
115
+
116
+ val killTime = minOf(now, maxOf(activeStart, state.lastActiveAt ?: activeStart))
117
+ val activeMs = Math.max(0L, killTime - activeStart)
118
+ val timeSpentMs = state.timeSpentMs + activeMs
119
+ val sessionEnd =
120
+ SessionEvent(
121
+ "session_end",
122
+ mapOf(
123
+ "sessionId" to state.sessionId,
124
+ "sessionNumber" to state.sessionNumber,
125
+ "subSession" to state.subSessionCount,
126
+ "activeMs" to activeMs,
127
+ "timeSpentMs" to timeSpentMs,
128
+ "sessionLengthMs" to Math.max(0L, killTime - state.firstForegroundAt),
129
+ "synthetic" to true,
130
+ ),
131
+ )
132
+ val closed =
133
+ state.copy(
134
+ lastBackgroundAt = killTime,
135
+ activeStart = null,
136
+ lastActiveAt = null,
137
+ timeSpentMs = timeSpentMs,
138
+ )
139
+ return listOf(sessionEnd) + resume(closed, now)
140
+ }
141
+
142
+ /** The foreground decision once no sub-session is open. */
143
+ private fun resume(state: SessionState?, now: Long): List<SessionEvent> {
64
144
  val gap = if (state?.lastBackgroundAt != null) now - state.lastBackgroundAt else null
65
145
 
66
146
  if (state == null || gap == null || gap >= windowMs) {
@@ -77,8 +157,8 @@ internal class SessionTracker(
77
157
  timeSpentMs = 0L,
78
158
  ),
79
159
  )
80
-
81
- val sessionStart =
160
+ ownsOpenSubSession = true
161
+ return listOf(
82
162
  SessionEvent(
83
163
  "session_start",
84
164
  mapOf(
@@ -89,32 +169,18 @@ internal class SessionTracker(
89
169
  // 0 rather than infinity, which would not survive JSON.
90
170
  "timeSinceLastSessionMs" to (gap ?: 0L),
91
171
  ),
92
- )
93
-
94
- if (state?.activeStart != null) {
95
- val killTime = state.lastBackgroundAt ?: now
96
- val activeMs = Math.max(0L, killTime - state.activeStart)
97
- val timeSpentMs = state.timeSpentMs + activeMs
98
- val sessionEnd =
99
- SessionEvent(
100
- "session_end",
101
- mapOf(
102
- "sessionId" to state.sessionId,
103
- "sessionNumber" to state.sessionNumber,
104
- "subSession" to state.subSessionCount,
105
- "activeMs" to activeMs,
106
- "timeSpentMs" to timeSpentMs,
107
- "sessionLengthMs" to Math.max(0L, killTime - state.firstForegroundAt),
108
- "synthetic" to true,
109
- ),
110
- )
111
- return listOf(sessionEnd, sessionStart)
112
- }
113
-
114
- return listOf(sessionStart)
172
+ ),
173
+ )
115
174
  }
116
175
 
117
- save(state.copy(subSessionCount = state.subSessionCount + 1L, activeStart = now))
176
+ save(
177
+ state.copy(
178
+ subSessionCount = state.subSessionCount + 1L,
179
+ activeStart = now,
180
+ lastActiveAt = null,
181
+ ),
182
+ )
183
+ ownsOpenSubSession = true
118
184
  return emptyList()
119
185
  }
120
186
 
@@ -125,7 +191,15 @@ internal class SessionTracker(
125
191
  val now = clock.nowMs()
126
192
  val activeMs = Math.max(0L, now - activeStart)
127
193
  val timeSpentMs = state.timeSpentMs + activeMs
128
- save(state.copy(lastBackgroundAt = now, activeStart = null, timeSpentMs = timeSpentMs))
194
+ save(
195
+ state.copy(
196
+ lastBackgroundAt = now,
197
+ activeStart = null,
198
+ lastActiveAt = null,
199
+ timeSpentMs = timeSpentMs,
200
+ ),
201
+ )
202
+ ownsOpenSubSession = false
129
203
  return SessionEvent(
130
204
  "session_end",
131
205
  mapOf(
@@ -157,6 +231,7 @@ internal class SessionTracker(
157
231
  // it, an app updating from an older SDK would fail to load its own session
158
232
  // and restart its numbering.
159
233
  val timeSpentMs = (map["timeSpentMs"] as? Number)?.toLong() ?: 0L
234
+ val lastActiveAt = (map["lastActiveAt"] as? Number)?.toLong()
160
235
  val firstForegroundAt =
161
236
  (map["firstForegroundAt"] as? Number)?.toLong()
162
237
  ?: activeStart
@@ -172,6 +247,7 @@ internal class SessionTracker(
172
247
  activeStart = activeStart,
173
248
  firstForegroundAt = firstForegroundAt,
174
249
  timeSpentMs = timeSpentMs,
250
+ lastActiveAt = lastActiveAt,
175
251
  )
176
252
  lastState = state
177
253
  return state